Skip to content
Merged
46 changes: 46 additions & 0 deletions .changeset/23543-f4-visibility-alias-keys-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/spec': minor
'@objectstack/lint': patch
'@objectstack/metadata-core': patch
---

feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path

Clause-②: yes (narrowing)

**BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`).

## FROM → TO

| you wrote (17.7 and earlier) | write instead |
| --- | --- |
| `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` |
| `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` |
| `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` |

**The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key.

**What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422:

> `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`.

**No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit.

**Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename.

## The retirement kit

- **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`.
- **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`.
- **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys.
- **No deprecation window**, per the project's startup-stage posture.

`@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape.

`@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first.

Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side.

⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.

<!-- adr-0087: registered ui-visibility-alias-keys-retired -->
12 changes: 7 additions & 5 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ sections:

<Callout type="warn">
**There is no section-level `visible` key — and no permission test belongs here.**
`FormSectionSchema` is `.strict()` and declares `visibleWhen` (plus the `@deprecated`
alias `visibleOn`); `visible` is refused *by name*, before any value shape is examined,
`FormSectionSchema` is `.strict()` and declares `visibleWhen` (the retired `visibleOn`
is refused by name since `@objectstack/spec` 18); `visible` is refused *by name*, before any value shape is examined,
and the rejection points you back at `visibleWhen`. Neither a `{ field, value }` rule
object nor a `{ permission }` name is a value `visibleWhen` accepts either — it takes a
CEL predicate string, normalized to a `{ dialect: 'cel', source }` envelope at parse.
Expand Down Expand Up @@ -945,9 +945,11 @@ only.** Nothing on the write path evaluates a form-view field or section
`visibleWhen`, and that is the whole list — so a `current_user.positions` test
written here hides controls and protects no data.

The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated`
aliases: still accepted and folded into `visibleWhen` at parse time, so existing
metadata keeps working. Author new metadata with `visibleWhen`.
The legacy spellings `visibleOn` (view) and `visibility` (page) were removed in
`@objectstack/spec` 18: the parse refuses each by name with the rename to
`visibleWhen`, so rename the key (the CEL value is unchanged). Stored view and
page rows written with the old key keep loading — rehydration renames it before
the parse.

Since `@objectstack/spec` 15 (ADR-0089 D3a) the form field/section and page
component schemas are **strict**: a key they do not declare — a `visibleWhen`
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ View filter rule
| **className** | `string` | optional | CSS class names |
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. |
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
Expand Down Expand Up @@ -442,7 +442,7 @@ Print declaration: paper size, orientation, margins, the running header and foot
| **className** | `string` | optional | CSS class names |
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. |
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
Expand Down
Loading
Loading