|
| 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