diff --git a/.changeset/visiblewhen-app-scope-root-prose.md b/.changeset/visiblewhen-app-scope-root-prose.md new file mode 100644 index 0000000000..5c0991aa42 --- /dev/null +++ b/.changeset/visiblewhen-app-scope-root-prose.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) + +Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. + +The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: + +- `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). +- `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. +- `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. + +Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). + +No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 73ef09ad6d..512a0a6c3c 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -253,7 +253,7 @@ View filter rule | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | 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.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | +| **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.`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. 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. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **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; apply them by hand. | @@ -351,7 +351,7 @@ View filter rule | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | 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.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | +| **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.`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. 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. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **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; apply them by hand. | diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 56dd841865..a959974f80 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -414,7 +414,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( ), /** * Visibility predicate (CEL) — same scope as the action-level `visible` - * (`current_user` / `app` / `data` / `features`). When it evaluates false the + * (`current_user` / `data` / `features`). When it evaluates false the * dialog omits this param entirely. Use it to hide a param that the backend * only accepts under an opt-in capability, e.g. the create-user `phoneNumber` * param gated on `features.phoneNumber` so the form never offers a field the @@ -1297,7 +1297,7 @@ const actionObject = () => strictObject({ /** * Whether the action is offered at all. Three arms, one meaning — see * {@link ActionConditionInputSchema}: `false` parks the action, `true` is the - * explicit default, and a predicate gates it per record/user/app/features. + * explicit default, and a predicate gates it per record/user/features. * * ⚠️ Client-side hiding is UX, not authorization — the button is gone, the * route is not. An action gated for access-control reasons must also be diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 4b888d0400..93b810481d 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -719,10 +719,10 @@ export const PageTabsProps = strictObject({ * binds it to the data-source ADAPTER. Same key, two meanings. * * **The row's bare fields are spread flat**, so `status` resolves as * well as `record.status`. The ambient scope is spread AFTER the row, - * so an ambient root (`app`, `features`, `user`, …) wins over a record + * so an ambient root (`features`, `user`, …) wins over a record * field of the same name. * - * Like the component-node surface it also mounts the ambient `app` / + * Like the component-node surface it also mounts the ambient * `features` / `os.user` roots, which no ADR rules for a UI predicate * (ADR-0068's Non-goals: "only the user object is in scope here"). * diff --git a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts new file mode 100644 index 0000000000..45854da113 --- /dev/null +++ b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17203] `app` is NOT an expression-scope root, and no prose face of the UI + * schemas may say it is. + * + * ## The fact being pinned + * + * `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and + * ADR-0068 has never ruled it. Decision batch #67 (2026-09-07) ruled option B + * — the engine's `SCOPE_ROOTS` is the contract and the renderer aligns to it — + * and ObjectUI shipped that: `buildExpressionScope` no longer binds `app`. The + * producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was + * closed `not_planned` in the same ruling. + * + * So every sentence in this package that told an author `app` is a root the + * renderer mounts was describing a binding that no longer exists — and it was + * the LAST surface anywhere that could still teach an author, or a + * metadata-generating agent (ADR-0033 lists AI as a primary consumer of these + * `.describe()` strings), to write `app.tier == 'pro'`. + * + * ## Why that mattered enough to pin + * + * The resulting predicate does not fail uniformly, and it is silent either + * way: a field `visibleWhen` and a nav / area `visible` fail **OPEN** (the + * gate stops hiding), while a conditional-formatting `condition` and a + * row-action `visible` / `disabled` fail **CLOSED** (the rule silently stops + * matching). An author sees nothing but a console line. + * + * ## The six faces + * + * Two of them are published — `.describe()` text reaches authoring tools and + * is republished verbatim into `content/docs/references/ui/page.mdx` by + * `build-docs.ts`. The other four are TSDoc, which no generator reads, so they + * are seen only by whoever opens the file — often an AI author. That is + * exactly why the first probe of this class missed some of them, and why the + * pin covers both kinds. + * + * ⛔ **Scope: the claim, not the wording.** Rephrasing these sentences, + * reordering the surviving roots, or documenting a root that genuinely gets + * bound later is free. Re-introducing `app` into a scope-root enumeration on + * any of these faces is not. + * + * ⛔ This file must NOT restate which roots `SCOPE_ROOTS` declares — that list + * is `@objectstack/formula`'s, tested there. The assertions below are about + * what these six sentences claim, which is a fact about this package's text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { PageComponentSchema } from './page.zod'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const read = (f: string) => fs.readFileSync(path.resolve(HERE, f), 'utf8'); + +const pageSource = read('page.zod.ts'); +const actionSource = read('action.zod.ts'); +const componentSource = read('component.zod.ts'); + +/** + * The `app` token in a SCOPE-ROOT position — never the `app` metadata type, + * which is a different word that legitimately appears all over these files + * (`app` vs `utility` page types, `app.branding`, the `app` package type…). + * + * A bare /app/ search over any of these files matches dozens of those and is + * therefore not a reading. Each assertion below is scoped to ONE sentence, + * located by an anchor that survives rewording of everything around it. + */ +const sentenceContaining = (source: string, anchor: string): string => { + const at = source.indexOf(anchor); + expect(at, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeGreaterThan(-1); + // The docblock sentence: from the anchor to the next period that ends it. + const tail = source.slice(at, at + 400); + return tail.replace(/\n\s*\*\s?/g, ' '); +}; + +/** Root tokens that are still true on these surfaces and must stay in place. */ +const SURVIVING_ROOTS = ['features', 'os.user'] as const; + +describe('#17203 — no UI prose face advertises `app` as an expression-scope root', () => { + describe('published faces (read by authoring tools and republished into the reference docs)', () => { + it('`PageComponentSchema.visibleWhen`.describe() does not name `app` among the mounted roots', () => { + // ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)` + // pipe (see `lazySchema`'s docblock), so it is a ZodPipe and the object + // with the property descriptions is its INPUT side. Reaching for `.shape` + // here yields `undefined` and every assertion below would then throw + // rather than measure. + const shape = (PageComponentSchema as unknown as { + def: { in: { shape: Record } }; + }).def.in.shape; + const description = shape.visibleWhen.description; + + expect(description, 'the describe() must exist — this pin is about its content').toBeTruthy(); + const mounts = description!.slice(description!.indexOf('additionally mounts')); + + // The claim: whatever this sentence says the renderer mounts, `app` is not in it. + expect(mounts).not.toMatch(/`app`/); + + // Survival controls — deleting the token must not have taken the sentence with it. + for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root); + expect(mounts).toContain('`data`'); + expect(mounts).toContain('NOT contract-guaranteed'); + + // Contract-bound roots are a different clause and are untouched. + expect(description).toContain('`record`'); + expect(description).toContain('`current_user`'); + }); + }); + + describe('TSDoc faces (no generator reads these — an AI author opening the file does)', () => { + it('page.zod.ts — the "Ambient roots" docblock', () => { + const s = sentenceContaining(pageSource, 'The shipping renderer additionally mounts'); + + expect(s).not.toMatch(/`app`/); + for (const root of SURVIVING_ROOTS) expect(s).toContain(root); + expect(s).toContain('binds `data`'); + }); + + it('action.zod.ts — the param-level `visible` scope list', () => { + const s = sentenceContaining(actionSource, 'same scope as the action-level'); + + expect(s).not.toMatch(/`app`/); + expect(s).toContain('`current_user`'); + expect(s).toContain('`data`'); + expect(s).toContain('`features`'); + }); + + it('action.zod.ts — the action-level `visible` scope list, stated unbackticked', () => { + // This face states the same claim in different words — `record/user/app/features`, + // no backticks. A probe shaped for the backticked token could not see it. + const s = sentenceContaining(actionSource, 'a predicate gates it per'); + + expect(s).not.toMatch(/\bapp\b/); + expect(s).toContain('record/user/features'); + }); + + it('component.zod.ts — the ambient-root name-resolution example', () => { + const s = sentenceContaining(componentSource, 'so an ambient root ('); + + expect(s).not.toMatch(/`app`/); + expect(s).toContain('`features`'); + expect(s).toContain('`user`'); + }); + + it('component.zod.ts — the page:tabs "also mounts the ambient …" sentence', () => { + const s = sentenceContaining(componentSource, 'it also mounts the ambient'); + + expect(s).not.toMatch(/`app`/); + for (const root of SURVIVING_ROOTS) expect(s).toContain(root); + }); + }); + + describe('probe controls — a zero above is only a reading if these hold', () => { + /** + * LIT. `page.zod.ts` still says `app` twice, both times about the page + * TYPE (`app` vs `utility` vs `blank`) — a different word that this card + * deliberately did NOT touch. It is the standing proof that a bare /app/ + * probe over this file cannot answer the scope-root question, and that the + * anchored, sentence-scoped assertions above are the required shape. + * + * If this ever reads 0, someone deleted the page-type prose and the + * assertions above quietly became unable to distinguish a real regression + * from a file that simply stopped using the word. + */ + it('LIT: the `app` page TYPE prose survives, so a scoped probe is still required', () => { + expect(pageSource).toContain('`app` is an app-level page'); + // THREE occurrences on TWO lines — `grep -c` answers lines and reads 2, + // which is the whole reason this is asserted on occurrences instead. + expect(pageSource.match(/`app`/g) ?? []).toHaveLength(3); + }); + + /** + * DARK. A fabricated token, which must read absent everywhere. It proves + * the `not.toMatch` / `not.toContain` arms above are wired to something + * that can actually fail, rather than passing on an empty haystack. + */ + it('DARK: a fabricated root token reads absent on every face', () => { + for (const source of [pageSource, actionSource, componentSource]) { + expect(source).not.toContain('`appzz_scope_root`'); + } + }); + + /** + * The other half of the dark control: the helper must throw when its + * anchor is gone, so a site that gets renamed out from under this pin + * fails loudly instead of asserting over an empty string. + */ + it('DARK: a missing anchor fails the pin rather than passing vacuously', () => { + expect(() => sentenceContaining(pageSource, 'no such anchor exists in this file')).toThrow(); + }); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 4f30ce88c0..cbdfd362ae 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -319,7 +319,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * * ## Ambient roots — renderer behaviour, NOT contract-guaranteed * - * The shipping renderer additionally mounts `app`, `features` and `os.user` + * The shipping renderer additionally mounts `features` and `os.user` * from app-shell's `ExpressionProvider`, and binds `data`. **No ADR rules * those on this surface**: ADR-0068's Non-goals fence its ruling to the user * object ("only the user object is in scope here"), and ADR-0058 governs @@ -342,7 +342,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * the record **ROW** instead. Same key name, two bindings; see that key's own * describe in `component.zod.ts` rather than assuming this one carries over. */ - visibleWhen: ExpressionInputSchema.optional().describe("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.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. \"page.selectedProjectId != ''\""), + visibleWhen: ExpressionInputSchema.optional().describe("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.`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. \"page.selectedProjectId != ''\""), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibility: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),