Skip to content

Commit edbe460

Browse files
committed
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
1 parent 29308ba commit edbe460

7 files changed

Lines changed: 448 additions & 8 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
39+
**The prescription is filed on those two shapes, not on the table they share.**
40+
`VISIBILITY_STRICT_OPTIONS` has a third consumer, `FormFieldSchema`, which is
41+
the one view/page shape that *does* answer `disabled` — through its own
42+
`disabled → readonly` rename. A guidance set consumes a key before the rename
43+
channel is ever consulted, so filing this family in the shared table would have
44+
replaced the family's one correct pointer with a redirect away from it. Section
45+
and component take a new `VISIBILITY_ONLY_STRICT_OPTIONS`; the field shape keeps
46+
the bare options and its message is byte-for-byte what it was.

‎packages/spec/export-origins/shared.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"TemplateExpressionInput": "src/shared/expression.zod.ts#TemplateExpressionInput (type)",
9292
"TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)",
9393
"VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)",
94+
"VISIBILITY_ONLY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_ONLY_STRICT_OPTIONS (const)",
9495
"VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)",
9596
"ViewName": "src/shared/branded-types.zod.ts#ViewName (type)",
9697
"ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)",
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
35+
import { describe, it, expect } from 'vitest';
36+
37+
import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod';
38+
import { PageComponentSchema } from '../ui/page.zod';
39+
import { VISIBILITY_ONLY_STRICT_OPTIONS } from './visibility';
40+
import { keySetMatches } from './suggestions.zod';
41+
42+
/**
43+
* The `unrecognized_keys` message for `value`, or a loud failure.
44+
*
45+
* Same helper, same reasoning, as `visible-when-alias-guidance.test.ts`: the
46+
* probe bodies are minimal and may also miss a required key, so a whole-error
47+
* stringify could let an assertion pass on text from an unrelated issue.
48+
*/
49+
function unknownKeyMessage(
50+
schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } },
51+
value: unknown,
52+
): string {
53+
const r = schema.safeParse(value);
54+
expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false);
55+
const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? [];
56+
const hit = issues.find((i) => i.code === 'unrecognized_keys');
57+
expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined();
58+
return hit?.message ?? '';
59+
}
60+
61+
/** Minimal bodies that reach each surface's unknown-key path. */
62+
const SECTION = { fields: [] } as const;
63+
const COMPONENT = { type: 'text' } as const;
64+
const FORM_FIELD = { field: 'probe' } as const;
65+
66+
/** The two shapes the ruling names, and nothing else. */
67+
const VISIBILITY_ONLY: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object]> = [
68+
['FormSectionSchema', FormSectionSchema, SECTION],
69+
['PageComponentSchema', PageComponentSchema, COMPONENT],
70+
];
71+
72+
/** The spellings the boundary set answers. */
73+
const EDITABILITY_KEYS = ['disabled', 'disabledWhen', 'readonly', 'readOnly', 'readonlyWhen', 'editable'] as const;
74+
75+
// ===========================================================================
76+
// 1. The guidance reaches an author — on the real parse error
77+
// ===========================================================================
78+
describe('#7887 — the boundary prescription an author actually sees', () => {
79+
it.each(VISIBILITY_ONLY)('%s answers `disabled` with the boundary, not a bare refusal', (_n, schema, base) => {
80+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
81+
expect(m).toContain('Editability is a FIELD-level concern');
82+
expect(m).toContain('gates VISIBILITY only');
83+
// Rendered through the shared template's prescription channel — the bullet
84+
// is what puts it directly after the key statement and before the history
85+
// sentence (#5955's ordering, pinned for this family in `ui/view.test.ts`).
86+
expect(m).toContain('\n • Editability is a FIELD-level concern');
87+
});
88+
89+
it.each(VISIBILITY_ONLY)('%s answers the whole editability family, not just `disabled`', (_n, schema, base) => {
90+
for (const key of EDITABILITY_KEYS) {
91+
expect(
92+
unknownKeyMessage(schema, { ...base, [key]: 'x' }),
93+
`\`${key}\` should reach the boundary prescription`,
94+
).toContain('Editability is a FIELD-level concern');
95+
}
96+
});
97+
98+
it.each(VISIBILITY_ONLY)('%s emits the prescription ONCE for a body carrying several of them', (_n, schema, base) => {
99+
// The property that makes this a SET rather than N exact entries: one
100+
// paragraph per message, however many members were written.
101+
const m = unknownKeyMessage(schema, { ...base, disabled: true, readonly: true, editable: false });
102+
expect(m.split('Editability is a FIELD-level concern')).toHaveLength(2);
103+
// …and every offending key is still named.
104+
for (const key of ['disabled', 'readonly', 'editable']) expect(m).toContain(`\`${key}\``);
105+
});
106+
107+
it.each(VISIBILITY_ONLY)('%s still puts the history sentence last (the #5955 order survives the new set)', (_n, schema, base) => {
108+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
109+
const history = 'Before ADR-0089 D3a these were dropped silently';
110+
expect(m.indexOf('Editability is a FIELD-level concern')).toBeLessThan(m.indexOf(history));
111+
});
112+
});
113+
114+
// ===========================================================================
115+
// 2. It names `readonlyWhen` — and must never name `disabledWhen`
116+
// ===========================================================================
117+
describe('#7887 — the prescription points at a key that exists', () => {
118+
it.each(VISIBILITY_ONLY)('%s names the field-level `readonly` / `readonlyWhen` pair', (_n, schema, base) => {
119+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
120+
expect(m).toContain('`readonly: true`');
121+
expect(m).toContain('`readonlyWhen`');
122+
});
123+
124+
it.each(VISIBILITY_ONLY)('%s never names `disabledWhen` — no field surface declares it', (_n, schema, base) => {
125+
// `field.zod.ts` renames `disabled → readonly` precisely because a field
126+
// has `readonlyWhen`, not `disabledWhen` (#7832). Pointing at the latter
127+
// would be a rejection that hands the author their next rejection.
128+
const m = unknownKeyMessage(schema, { ...base, disabledWhen: 'record.locked' });
129+
expect(m).toContain('Editability is a FIELD-level concern');
130+
// The offending key is echoed back in the front matter, so the prohibition
131+
// is on the PRESCRIPTION text, which is everything after the bullet.
132+
const prescription = m.slice(m.indexOf('\n • '));
133+
expect(prescription).not.toContain('disabledWhen');
134+
});
135+
136+
it('the boundary also names the visibility escape hatch, and that key really is accepted', () => {
137+
const m = unknownKeyMessage(FormSectionSchema, { ...SECTION, disabled: true });
138+
expect(m).toContain('`visibleWhen`');
139+
expect(FormSectionSchema.safeParse({ ...SECTION, visibleWhen: 'record.x' }).success).toBe(true);
140+
expect(PageComponentSchema.safeParse({ ...COMPONENT, visibleWhen: 'record.x' }).success).toBe(true);
141+
});
142+
});
143+
144+
// ===========================================================================
145+
// 3. The field surface is UNCHANGED — the shared-table trap
146+
// ===========================================================================
147+
describe('#7887 — `FormFieldSchema` sees exactly what it saw before', () => {
148+
it('`disabled` on a form field still renames onto `readonly`, with no boundary text', () => {
149+
const m = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true });
150+
expect(m).toContain('Did you mean `disabled` → `readonly`?');
151+
// The regression this whole file exists to catch. Hoisting
152+
// `EDITABILITY_BOUNDARY_KEYS` into `VISIBILITY_STRICT_OPTIONS` makes the set
153+
// fire here, and a set match `continue`s past the rename channel — so the
154+
// line above would vanish and this line would appear, redirecting a field
155+
// author AWAY from the one surface where `readonly` is real.
156+
expect(m).not.toContain('Editability is a FIELD-level concern');
157+
});
158+
159+
it('the boundary options are filed on the two visibility-only shapes, never the shared table', () => {
160+
const names = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []).map((s) => s.name);
161+
expect(names).toContain('EDITABILITY_BOUNDARY_KEYS');
162+
// The ADR-0089 set is still there and still first — the boundary set is an
163+
// addition, not a replacement.
164+
expect(names[0]).toBe('VISIBILITY_KEY_PATTERN');
165+
});
166+
167+
it('no editability key matches `VISIBILITY_KEY_PATTERN`, so set order is not load-bearing', () => {
168+
// Both sets live on one table. If a future edit widened the visibility
169+
// pattern to reach (say) `editable`, declaration order would silently start
170+
// deciding which prescription an author reads — the tie-break
171+
// `alias-integrity.test.ts` forbids any in-repo table from depending on.
172+
const visibility = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? [])
173+
.find((s) => s.name === 'VISIBILITY_KEY_PATTERN');
174+
expect(visibility).toBeDefined();
175+
for (const key of EDITABILITY_KEYS) {
176+
expect(keySetMatches(visibility!, key), `\`${key}\` is claimed by both sets`).toBe(false);
177+
}
178+
});
179+
180+
it('no alias row was registered for the boundary — the ruling forbids one', () => {
181+
// "no alias row is registered for them — an alias would declare a key the
182+
// runtime does not honour". An alias TARGET must be a key the shape accepts
183+
// (`alias-integrity.test.ts`), and neither shape accepts any of these, so a
184+
// row here would be a pointer into a second rejection.
185+
for (const table of [VISIBILITY_ONLY_STRICT_OPTIONS]) {
186+
for (const key of EDITABILITY_KEYS) {
187+
expect(table.aliases?.[key], `\`${key}\` must not have an alias row`).toBeUndefined();
188+
}
189+
}
190+
});
191+
});
192+
193+
// ===========================================================================
194+
// 4. Acceptance is byte-identical — a guidance string is not a key
195+
// ===========================================================================
196+
describe('#7887 — no acceptance change', () => {
197+
it.each(VISIBILITY_ONLY)('%s still REJECTS every editability spelling', (_n, schema, base) => {
198+
for (const key of EDITABILITY_KEYS) {
199+
expect(
200+
schema.safeParse({ ...base, [key]: true }).success,
201+
`\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`,
202+
).toBe(false);
203+
}
204+
});
205+
206+
it('a representative section that parsed before still parses, unchanged in output', () => {
207+
const authored = {
208+
name: 'billing',
209+
label: 'Billing',
210+
description: 'Invoicing details',
211+
collapsible: true,
212+
collapsed: false,
213+
columns: 2,
214+
visibleOn: 'record.type == "customer"',
215+
fields: ['amount', { field: 'currency', readonly: true }],
216+
};
217+
const r = FormSectionSchema.safeParse(authored);
218+
expect(r.success).toBe(true);
219+
// The ADR-0089 fold still runs, and the field-level `readonly` inside is
220+
// still the accepted way to say what `disabled` on the section cannot.
221+
// `ExpressionInputSchema` normalizes the authored string to a
222+
// `{ dialect, source }` pair; the fold is about WHICH KEY carries it.
223+
expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source)
224+
.toBe('record.type == "customer"');
225+
expect((r.data as { visibleOn?: unknown }).visibleOn).toBeUndefined();
226+
});
227+
228+
it('a representative page component that parsed before still parses, unchanged in output', () => {
229+
const authored = {
230+
type: 'record:form',
231+
id: 'main_form',
232+
label: 'Details',
233+
properties: { columns: 2 },
234+
className: 'p-4',
235+
visibility: 'current_user.is_admin',
236+
};
237+
const r = PageComponentSchema.safeParse(authored);
238+
expect(r.success).toBe(true);
239+
expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source)
240+
.toBe('current_user.is_admin');
241+
expect((r.data as { visibility?: unknown }).visibility).toBeUndefined();
242+
});
243+
244+
it.each(VISIBILITY_ONLY)('%s keeps its bare message for a key in no family at all', (_n, schema, base) => {
245+
// The new set must claim the editability family and nothing beyond it: an
246+
// unrelated typo still gets the front matter plus history and no bullet.
247+
const m = unknownKeyMessage(schema, { ...base, totallyUnrelatedKey: true });
248+
expect(m).toContain('`totallyUnrelatedKey`');
249+
expect(m).not.toContain(' • ');
250+
});
251+
});

0 commit comments

Comments
 (0)