Skip to content

Commit 113bd50

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0089 unify conditional-visibility predicate under visibleWhen (#2641)
Propose collapsing the same "show when CEL predicate is TRUE" concept — spelled visibleWhen (data field), visibleOn (view form), and visibility (page component) — onto a single canonical visibleWhen, keeping the old names as @deprecated aliases normalized at parse time. Chooses visibleWhen (not visibleOn) to stay aligned with the existing readonlyWhen/requiredWhen family and the conditionalRequired -> requiredWhen precedent. Adds .strict() + a lint rule so a mis-layered or mis-rooted key becomes a loud error instead of being silently stripped by zod. The boolean `visible` (Tab on/off) stays out of scope. Claude-Session: https://claude.ai/code/session_01PHCX3Qo8NjyMiMMMayygjg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0527461 commit 113bd50

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest
2+
3+
**Status**: Proposed (2026-07-05)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared-but-unchecked visibility key is exactly this class), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a mis-layered visibility key that zod strips is inert by accident), [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (the enforce-or-remove pass that already deleted a consumer-less `visibleOn` from `fieldGroups`), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the AI-authoring population this ADR optimizes for), [ADR-0058](./0058-expression-and-predicate-surface.md) (the CEL predicate surface these keys all belong to), [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (conversion-over-notification; this rename ships as an L1 invisible break via the alias mechanism)
6+
**Consumers**: `@objectstack/spec` (the field / view / page zod schemas + the alias normalization), `@objectstack/objectql` (`rule-validator` — the server-side enforcer), `@objectstack/lint` (the new mis-layer / wrong-root rule), ObjectUI form + page renderers, and every AI author of `*.object.ts` / `*.view.ts` / `*.page.ts`
7+
**Surfaced by**: the same-concept-many-names spread across the spec — a data field gates on `visibleWhen`, a view form field/section on `visibleOn`, a page component on `visibility` — combined with zod's default `.strip()` making a mis-layered key vanish silently rather than erroring. Directly parallels the resolved `conditionalRequired → requiredWhen` consolidation already in `field.zod.ts`.
8+
9+
---
10+
11+
## TL;DR
12+
13+
One concept — *"show this only when the CEL predicate is TRUE"* — is spelled three
14+
different ways depending on which layer you are in:
15+
16+
| Layer | Key today | Location |
17+
|---|---|---|
18+
| Data field / field option | `visibleWhen` | `packages/spec/src/data/field.zod.ts:592, 108` |
19+
| View form section / field | `visibleOn` | `packages/spec/src/ui/view.zod.ts:762, 781` |
20+
| Page component | `visibility` | `packages/spec/src/ui/page.zod.ts:109` |
21+
22+
Because every one is `.optional()` and the schemas run zod's default **strip** mode, a
23+
key placed on the wrong layer — `visibleOn` on a data field, `visibleWhen` on a form
24+
field — is **silently dropped**: no type error, no validation error, the element just
25+
renders unconditionally forever. For an AI author that is the worst failure mode there
26+
is: no signal to self-correct from.
27+
28+
**Decision:** make **`visibleWhen`** the single canonical name across all three layers;
29+
keep `visibleOn` and `visibility` as `@deprecated` aliases normalized to `visibleWhen`
30+
at parse time; add `.strict()` + a lint rule so a mis-layered or mis-rooted key becomes
31+
a **loud** error instead of a silent no-op. The boolean `visible` (Tab on/off) is a
32+
different type and concept and is explicitly **out of scope**.
33+
34+
This is not a new pattern — it is the exact move already made for
35+
`conditionalRequired → requiredWhen` (`field.zod.ts:594-598`), applied to visibility.
36+
37+
## Context
38+
39+
### The `*When` family already exists — and `visibleWhen` is in it
40+
41+
`field.zod.ts:592-594` defines a coherent conditional-rule triad on data fields:
42+
43+
```ts
44+
visibleWhen: ExpressionInputSchema.optional(), // shown when TRUE
45+
readonlyWhen: ExpressionInputSchema.optional(), // read-only when TRUE
46+
requiredWhen: ExpressionInputSchema.optional(), // required when TRUE
47+
```
48+
49+
These three are enforced together on the server by objectql's `rule-validator`
50+
(`packages/objectql/src/validation/rule-validator.ts`). `visibleWhen` is not a
51+
standalone choice — it is one leg of a three-legged family that shares a suffix, a
52+
binding environment (`record` + `current_user`), and an enforcement path.
53+
54+
### The precedent: `conditionalRequired → requiredWhen` is already resolved this way
55+
56+
The repo has already run this exact consolidation once. `requiredWhen` is the canonical
57+
name; `conditionalRequired` is retained as a `@deprecated` alias
58+
(`field.zod.ts:594-598`), and the enforcer resolves the pair with a coalesce:
59+
60+
```ts
61+
// packages/objectql/src/validation/rule-validator.ts:378
62+
const pred = def?.requiredWhen ?? def?.conditionalRequired;
63+
```
64+
65+
The established direction of consolidation is **toward `*When`**, with the old name kept
66+
as a back-compat alias. This ADR follows that precedent rather than inventing a new one.
67+
68+
### Why the spread is specifically hostile to AI authoring
69+
70+
Three compounding traps:
71+
72+
1. **Same concept, name switches by layer.** `visibleWhen` (data) vs `visibleOn` (view)
73+
vs `visibility` (page). The docs already need a disambiguation paragraph
74+
(`content/docs/protocol/objectui/layout-dsl.mdx:796-798`) — a sign the surface, not
75+
the reader, is the problem.
76+
77+
2. **`visibleOn` has two binding roots.** In runtime forms it binds the live record
78+
(`visibleOn: "record.status != 'closed'"`); in the *metadata-editing* forms
79+
(`object.form.ts`, `field.form.ts`, `page.form.ts`) it binds the row under edit
80+
(`visibleOn: "data.type == 'grid'"`). Same key, different root by context.
81+
82+
3. **Failure is silent.** The field/view/page schemas use zod's default strip mode
83+
(only two `.passthrough()` sites exist in `view.zod.ts`, none relevant here), so a
84+
mis-layered key is discarded with no diagnostic. ADR-0085 already deleted one
85+
consumer-less `visibleOn` from `fieldGroups` under the enforce-or-remove rule
86+
(`object.zod.ts:227-230`); this ADR closes the general case.
87+
88+
### Why unify to `visibleWhen` and not `visibleOn`
89+
90+
Choosing `visibleOn` as the canonical name would strand it next to `readonlyWhen` /
91+
`requiredWhen`, producing a broken triad (`visibleOn` + `readonlyWhen` + `requiredWhen`).
92+
To restore symmetry you would then have to rename `readonlyWhen`/`requiredWhen` to
93+
`*On` — a larger blast radius, against the just-established `*When` precedent, and
94+
touching the server enforcer. Unifying to `visibleWhen` keeps the data-layer family
95+
anchor **unchanged** and moves only the view/page spellings. Rough migration weight also
96+
favors it: `visibleWhen` ~9 source files, `visibleOn` ~13, page `visibility` ~34.
97+
98+
## Decision
99+
100+
**D1 — `visibleWhen` is the single canonical conditional-visibility key.**
101+
It is accepted on data fields, field options, view form sections/fields, and page
102+
components. Its value is a CEL predicate (`ExpressionInputSchema`); the element is shown
103+
only when the predicate is TRUE. The binding *root* remains determined by the layer
104+
(runtime record surfaces bind `record` + `current_user`; metadata-editing forms bind
105+
`data`) — this ADR unifies the *name*, not the environment, and the environment is
106+
documented per layer.
107+
108+
**D2 — `visibleOn` and `visibility` become `@deprecated` aliases, normalized at parse.**
109+
Both keys stay accepted by the schemas for back-compat and are normalized to
110+
`visibleWhen` during `parse()` (a zod `.transform()` that folds `visibleOn ?? visibility`
111+
into `visibleWhen` when the canonical key is absent). Normalizing **once at the schema
112+
boundary** is preferred over the consumer-side `??` coalesce used for
113+
`conditionalRequired`, so no renderer or validator re-implements the fallback — but the
114+
consumer-side resolution stays valid as a defense-in-depth read. Emitting canonical
115+
`visibleWhen` on build is covered by ADR-0087's conversion layer (this is an L1
116+
invisible break: zero consumer action required).
117+
118+
**D3 — mis-layered / mis-rooted keys become loud errors.**
119+
Two enforcement additions, per ADR-0049 (enforce-or-remove) and ADR-0078 (nothing
120+
silently inert):
121+
- Tighten the relevant object schemas so an unknown/mis-layered visibility key
122+
(e.g. a raw `visibleOn` surviving after the deprecation window, or a `visibleWhen`
123+
on a schema that has no visibility semantics) is a `.strict()` parse error rather
124+
than a silent strip.
125+
- Add a `@objectstack/lint` rule that flags (a) a deprecated alias in freshly authored
126+
source (autofix → `visibleWhen`), and (b) a predicate whose binding root does not
127+
match its layer (`data.` in a runtime form predicate, or `record.` in a metadata
128+
form predicate).
129+
130+
**D4 — the boolean `visible` is out of scope.**
131+
`view.zod.ts:276` `visible: z.boolean()` (Tab on/off) is a static flag, not a predicate.
132+
Folding it into `visibleWhen` would create a new type ambiguity (boolean vs expression).
133+
It keeps its name and type. `hidden` (field boolean) and `visibleFields` (gallery card
134+
array) are likewise unrelated and untouched.
135+
136+
## Consequences
137+
138+
**Positive**
139+
- One name to learn, aligned with the existing `readonlyWhen`/`requiredWhen` family and
140+
the `conditionalRequired → requiredWhen` precedent — less to get wrong, for AI and
141+
humans alike.
142+
- The dominant failure mode flips from **silent drop** to **loud error** (D3), giving an
143+
AI author the correction signal it currently lacks.
144+
- Zero breaking change on rollout: existing `visibleOn` / `visibility` metadata keeps
145+
working through the alias (D2), consistent with ADR-0087's conversion contract.
146+
147+
**Negative / cost**
148+
- Touch cost across ~13 `visibleOn` + ~34 `visibility` source sites (mechanical codemod)
149+
plus renderer reads, the deprecation registry entry, and docs.
150+
- A deprecation window during which two spellings coexist — the same transient state
151+
`conditionalRequired`/`requiredWhen` already lives in; acceptable and precedented.
152+
- `.strict()` tightening risks surfacing pre-existing mis-layered keys elsewhere; these
153+
are latent bugs today (silently inert) and surfacing them is the point, but the rollout
154+
must sweep the monorepo + example apps before flipping strict on.
155+
156+
## Alternatives considered
157+
158+
1. **Unify to `visibleOn`** — rejected: breaks the `*When` triad and inverts the
159+
established consolidation direction (see Context).
160+
2. **Do nothing / document harder** — rejected: the disambiguation paragraph already
161+
exists and the trap persists; ADR-0049/0078 argue against leaving a silent-strip
162+
footgun in place.
163+
3. **Add lint only, keep three names** — rejected as a half-measure: lint reduces the
164+
error rate but the three-name surface remains the root cause; the alias makes
165+
unification free, so there is no reason to stop at lint.
166+
4. **Fold boolean `visible` in too** — rejected: type ambiguity (D4).
167+
168+
## Migration
169+
170+
1. Add `visibleWhen` to the view (section + field) and page component schemas; mark
171+
`visibleOn` / `visibility` `@deprecated`; add the parse-time normalization (D2).
172+
2. Register the rename in the deprecation / `spec-changes.json` registry per ADR-0087 so
173+
downstream conversion + `migrate meta` pick it up.
174+
3. Codemod first-party sources (`packages/`, `examples/`) `visibleOn`/`visibility`
175+
`visibleWhen`; update `content/docs/**` and the `objectstack-ui` / `objectstack-data`
176+
skills, including the layer→binding-root table.
177+
4. Land the lint rule (D3b) with autofix; sweep the repo; then flip `.strict()` (D3a).
178+
5. Keep the aliases for the standard deprecation window, then remove in a future major.

0 commit comments

Comments
 (0)