Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/visiblewhen-app-scope-root-prose.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ View filter rule
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **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 `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.<var>`. 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. |
Expand Down Expand Up @@ -351,7 +351,7 @@ View filter rule
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **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 `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.<var>`. 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. |
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
*
Expand Down
196 changes: 196 additions & 0 deletions packages/spec/src/ui/expression-scope-app-root.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { description?: string }> } };
}).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();
});
});
});
Loading
Loading