Skip to content

Commit 8244353

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

7 files changed

Lines changed: 123 additions & 94 deletions

File tree

‎.changeset/section-component-editability-boundary.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ reason.
3535
**Acceptance is unchanged, in both directions.** Every metadata document that
3636
parsed before parses identically, and every key rejected before is still
3737
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.
3841

3942
**The prescription is filed on those two shapes, not on the table they share.**
4043
`VISIBILITY_STRICT_OPTIONS` has a third consumer, `FormFieldSchema`, which is

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@
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)",
9594
"VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)",
9695
"ViewName": "src/shared/branded-types.zod.ts#ViewName (type)",
9796
"ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)",

‎packages/spec/src/shared/editability-boundary.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,18 @@
3030
* replaced the one correct pointer in the family with a redirect away from it.
3131
* 4. **acceptance is byte-identical** — the lane's admission criterion. A
3232
* 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.
3338
*/
3439

3540
import { describe, it, expect } from 'vitest';
3641

3742
import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod';
3843
import { PageComponentSchema } from '../ui/page.zod';
39-
import { VISIBILITY_ONLY_STRICT_OPTIONS } from './visibility';
44+
import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary';
4045
import { keySetMatches } from './suggestions.zod';
4146

4247
/**
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* # The section / page-component **editability boundary** (#7887)
5+
*
6+
* Maintainer ruling, 2026-08-12: `FormSectionSchema` (`ui/view.zod.ts`) and
7+
* `PageComponentSchema` (`ui/page.zod.ts`) gate **visibility only**; editability
8+
* lives on fields. No `disabled` / `readonly` / `readonlyWhen` slot is added to
9+
* either shape, and no alias row is registered for them — an alias names a key
10+
* the shape must then accept, and one the runtime does not honour is the
11+
* ADR-0049 declared-but-unenforced class this repo is retiring elsewhere.
12+
*
13+
* What the ruling *does* buy the author is this module: the rejection stops
14+
* being bare and starts naming where the key belongs.
15+
*
16+
* ## Package-internal on purpose — this module is NOT in `shared/index.ts`
17+
*
18+
* It sits beside `strict-object.ts` and `alias-probe.ts` in the set of shared
19+
* modules the barrel deliberately does not re-export. An unknown-key options
20+
* table is machinery for declaring schemas in *this* package, and its own type
21+
* (`StrictObjectOptions`) is not public either — a published const of an
22+
* unpublishable type is an export no consumer can even annotate.
23+
*
24+
* It also keeps the #7887 claim exactly true: the card's whole deliverable is
25+
* that nothing observable moves except the sentence an author reads, and the
26+
* package's public API surface (`check:api-surface`) does not move at all.
27+
*/
28+
29+
import type { StrictObjectOptions } from './strict-object';
30+
import type { KeySetGuidance } from './suggestions.zod';
31+
import { VISIBILITY_STRICT_OPTIONS } from './visibility';
32+
33+
/**
34+
* The editability vocabulary an author reaches for on a shape that gates
35+
* **visibility only**.
36+
*
37+
* Every spelling here is rejected by `FormSectionSchema` and
38+
* `PageComponentSchema` today and stays rejected: this set changes the MESSAGE,
39+
* never the verdict. `readOnly` sits alongside `readonly` because set
40+
* membership is matched case-sensitively (the rename channel is what folds
41+
* case, and a set match `continue`s past it).
42+
*/
43+
const EDITABILITY_BOUNDARY_KEYS = [
44+
'disabled',
45+
'disabledWhen',
46+
'readonly',
47+
'readOnly',
48+
'readonlyWhen',
49+
'editable',
50+
] as const;
51+
52+
/**
53+
* The ruling rendered as the thing an author actually reads: **boundary, not
54+
* gap.**
55+
*
56+
* Deliberately points at **`readonlyWhen`** and not at `disabledWhen`:
57+
* `field.zod.ts` renames `disabled → readonly` and records in its own comment
58+
* that "a field has `readonlyWhen`, not `disabledWhen`" (#7832). Naming
59+
* `disabledWhen` here would send an author to a key that exists on no field
60+
* surface at all — a rejection that hands them their next rejection.
61+
*/
62+
const EDITABILITY_BOUNDARY_GUIDANCE: KeySetGuidance = {
63+
name: 'EDITABILITY_BOUNDARY_KEYS',
64+
keys: EDITABILITY_BOUNDARY_KEYS,
65+
prescription:
66+
'Editability is a FIELD-level concern. This shape gates VISIBILITY only — a '
67+
+ 'deliberate boundary, not a missing key (#7887): a section / page component has '
68+
+ 'no read-only semantics of its own to enforce. Write `readonly: true` (or the '
69+
+ 'conditional `readonlyWhen` predicate) on the form field(s) inside it instead; to '
70+
+ 'hide the whole section or component, use `visibleWhen`.',
71+
};
72+
73+
/**
74+
* {@link VISIBILITY_STRICT_OPTIONS} for the two shapes that gate visibility and
75+
* **nothing else** — `FormSectionSchema` and `PageComponentSchema`.
76+
*
77+
* ## Why the boundary prescription is filed HERE and not in the shared table
78+
*
79+
* `VISIBILITY_STRICT_OPTIONS` has **three** consumers, and the third —
80+
* `FormFieldSchema` — is the one view/page shape that *does* answer `disabled`,
81+
* through its own `aliases: { disabled: 'readonly' }` row (`view.zod.ts`, whose
82+
* comment at that site rejects shared-table filing for exactly this reason).
83+
* Adding `EDITABILITY_BOUNDARY_KEYS` to the shared options would land it on that
84+
* table too, and the consequences are not cosmetic:
85+
*
86+
* - `strictUnknownKeyError` consults exact `guidance` → `guidanceSets` →
87+
* `aliases`, and a set match `continue`s past the rename. The field author who
88+
* writes `disabled` would stop seeing *"Did you mean `disabled` → `readonly`?"*
89+
* and start being told editability is somewhere else — on the one surface
90+
* where it is right there.
91+
* - `alias-integrity.test.ts` would go **red**, not quietly wrong, in two
92+
* places: its #7889 check fails any alias row a guidanceSet on the same table
93+
* already consumes, and its #6619 check fails a set member the shape
94+
* *declares* — which `readonly` is, on `FormFieldSchema`.
95+
*
96+
* So the two visibility-only shapes take these options and `FormFieldSchema`
97+
* keeps the bare ones. The prescription text is written once, here.
98+
*/
99+
export const VISIBILITY_ONLY_STRICT_OPTIONS: StrictObjectOptions = {
100+
...VISIBILITY_STRICT_OPTIONS,
101+
guidanceSets: [
102+
// Declaration order decides among sets. Nothing in
103+
// `EDITABILITY_BOUNDARY_KEYS` matches `VISIBILITY_KEY_PATTERN`
104+
// (`/vis|conceal|hidden|show.?when/i`), so the order is not load-bearing —
105+
// pinned in `editability-boundary.test.ts` so it cannot quietly become so.
106+
...(VISIBILITY_STRICT_OPTIONS.guidanceSets ?? []),
107+
EDITABILITY_BOUNDARY_GUIDANCE,
108+
],
109+
};

‎packages/spec/src/shared/visibility.ts‎

Lines changed: 0 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { StrictObjectOptions } from './strict-object';
4-
import type { KeySetGuidance } from './suggestions.zod';
54

65
/**
76
* # Conditional-visibility predicate normalization (ADR-0089)
@@ -139,87 +138,3 @@ export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = {
139138
},
140139
],
141140
};
142-
143-
/**
144-
* The editability vocabulary an author reaches for on a shape that gates
145-
* **visibility only** (#7887).
146-
*
147-
* Every spelling here is rejected by `FormSectionSchema` and
148-
* `PageComponentSchema` today and stays rejected: this set changes the MESSAGE,
149-
* never the verdict. `readOnly` sits alongside `readonly` because set
150-
* membership is matched case-sensitively (the rename channel is what folds
151-
* case, and a set match `continue`s past it).
152-
*/
153-
const EDITABILITY_BOUNDARY_KEYS = [
154-
'disabled',
155-
'disabledWhen',
156-
'readonly',
157-
'readOnly',
158-
'readonlyWhen',
159-
'editable',
160-
] as const;
161-
162-
/**
163-
* The maintainer's 2026-08-12 ruling on #7887, rendered as the thing an author
164-
* actually reads: **boundary, not gap.**
165-
*
166-
* A form section and a page component gate *visibility*; **editability lives on
167-
* fields**. No `disabled` / `readonly` / `readonlyWhen` slot is added to either
168-
* shape, and no alias row is registered for them either — an alias names a key
169-
* the shape must then accept, and one the runtime does not honour is the
170-
* ADR-0049 declared-but-unenforced class this repo is retiring. What the author
171-
* gets instead is a prescription telling them where the key *does* belong.
172-
*
173-
* Deliberately points at **`readonlyWhen`** and not at `disabledWhen`:
174-
* `field.zod.ts` renames `disabled → readonly` and records in its own comment
175-
* that "a field has `readonlyWhen`, not `disabledWhen`" (#7832). Naming
176-
* `disabledWhen` here would send an author to a key that exists on no field
177-
* surface at all.
178-
*/
179-
const EDITABILITY_BOUNDARY_GUIDANCE: KeySetGuidance = {
180-
name: 'EDITABILITY_BOUNDARY_KEYS',
181-
keys: EDITABILITY_BOUNDARY_KEYS,
182-
prescription:
183-
'Editability is a FIELD-level concern. This shape gates VISIBILITY only — a '
184-
+ 'deliberate boundary, not a missing key (#7887): a section / page component has '
185-
+ 'no read-only semantics of its own to enforce. Write `readonly: true` (or the '
186-
+ 'conditional `readonlyWhen` predicate) on the form field(s) inside it instead; to '
187-
+ 'hide the whole section or component, use `visibleWhen`.',
188-
};
189-
190-
/**
191-
* {@link VISIBILITY_STRICT_OPTIONS} for the two shapes that gate visibility and
192-
* **nothing else** — `FormSectionSchema` (`ui/view.zod.ts`) and
193-
* `PageComponentSchema` (`ui/page.zod.ts`).
194-
*
195-
* ## Why the boundary prescription is filed HERE and not in the shared table
196-
*
197-
* `VISIBILITY_STRICT_OPTIONS` has **three** consumers, and the third —
198-
* `FormFieldSchema` — is the one view/page shape that *does* answer `disabled`,
199-
* through its own `aliases: { disabled: 'readonly' }` row (`view.zod.ts`, the
200-
* comment at that site rejects shared-table filing for exactly this reason).
201-
* Adding `EDITABILITY_BOUNDARY_KEYS` to the shared options would land it on that
202-
* table too, and the consequences are not cosmetic:
203-
*
204-
* - `strictUnknownKeyError` consults exact `guidance` → `guidanceSets` →
205-
* `aliases`, and a set match `continue`s past the rename. The field author who
206-
* writes `disabled` would stop seeing *"Did you mean `disabled` → `readonly`?"*
207-
* and start being told editability is somewhere else — on the one surface
208-
* where it is right there.
209-
* - `alias-integrity.test.ts` would go **red**, not quietly wrong: its #7889
210-
* check fails any alias row a guidanceSet on the same table already consumes.
211-
*
212-
* So the two visibility-only shapes take these options and `FormFieldSchema`
213-
* keeps the bare ones. The prescription text itself is written once, here.
214-
*/
215-
export const VISIBILITY_ONLY_STRICT_OPTIONS: StrictObjectOptions = {
216-
...VISIBILITY_STRICT_OPTIONS,
217-
guidanceSets: [
218-
// Declaration order decides among sets. Nothing in
219-
// `EDITABILITY_BOUNDARY_KEYS` matches `VISIBILITY_KEY_PATTERN`
220-
// (`/vis|conceal|hidden|show.?when/i`), so the order is not load-bearing —
221-
// pinned in `editability-boundary.test.ts` so it cannot quietly become so.
222-
...(VISIBILITY_STRICT_OPTIONS.guidanceSets ?? []),
223-
EDITABILITY_BOUNDARY_GUIDANCE,
224-
],
225-
};

‎packages/spec/src/ui/page.zod.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import { z } from 'zod';
44
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
55
import { ExpressionInputSchema } from '../shared/expression.zod';
6-
import { normalizeVisibleWhen, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/visibility';
6+
import { normalizeVisibleWhen } from '../shared/visibility';
7+
import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary';
78
import { SortItemSchema } from '../shared/enums.zod';
89
import { FilterConditionSchema } from '../data/filter.zod';
910
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { strictObject, strictObjectError } from '../shared/strict-object';
77
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
88
import { ExpressionInputSchema } from '../shared/expression.zod';
9-
import {
10-
normalizeVisibleWhen,
11-
VISIBILITY_ONLY_STRICT_OPTIONS,
12-
VISIBILITY_STRICT_OPTIONS,
13-
} from '../shared/visibility';
9+
import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility';
10+
import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary';
1411
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
1512
import { ChartTypeSchema } from './chart.zod';
1613
import { SharingConfigSchema } from './sharing.zod';
@@ -1779,7 +1776,7 @@ const FormFieldBaseSchema = lazySchema(() => {
17791776
//
17801777
// #7887 filed the OTHER half of that split from the same reasoning and in
17811778
// the same direction: the two sibling shapes now carry an editability
1782-
// BOUNDARY prescription (`VISIBILITY_ONLY_STRICT_OPTIONS`), and it is
1779+
// BOUNDARY prescription (`shared/editability-boundary.ts`), and it is
17831780
// filed on those two rather than shared, because a `disabled`-matching
17841781
// guidanceSet on THIS table would consume the key before the rename below
17851782
// ever runs — killing the one pointer that is correct here, and turning

0 commit comments

Comments
 (0)