Skip to content

Commit 52bc5ea

Browse files
os-billclaude
andcommitted
fix(spec): stop advertising app as an expression-scope root in UI schema prose (#17203)
Six prose faces of the UI schemas told an author a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and never contractually did: `SCOPE_ROOTS` has never declared `app`, ADR-0068 has never ruled it, and decision batch #67 ruled option B, which ObjectUI shipped by dropping the binding. Deletes the `app` token from all six faces, leaving `features`, `os.user`, `data`, `current_user`, `record` and `user` in place and in order, and the "renderer behaviour, NOT contract-guaranteed" framing verbatim: - ui/page.zod.ts — "Ambient roots" docblock + the published `.describe()` on `PageComponentSchema.visibleWhen` - ui/action.zod.ts — param-level `visible` docblock + the action-level `visible` docblock, which stated the same claim unbackticked (`record/user/app/features`) - ui/component.zod.ts — the `page:tabs` ambient-root resolution example and its "also mounts the ambient …" sentence The two latter faces were invisible to the token-co-occurrence probe that found the first three; the probe here searched by the claim instead. Regenerates content/docs/references/ui/page.mdx, which republishes the `.describe()` verbatim, and adds a pin test over all six faces with lit and dark probe controls. No accept set moves: `SCOPE_ROOTS` is untouched and every schema parses exactly what it parsed before. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47863f4 commit 52bc5ea

6 files changed

Lines changed: 221 additions & 8 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203)
6+
7+
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.
8+
9+
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:
10+
11+
- `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).
12+
- `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.
13+
- `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence.
14+
15+
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).
16+
17+
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.

content/docs/references/ui/page.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ View filter rule
253253
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
254254
| **className** | `string` | optional | CSS class names |
255255
| **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) |
256-
| **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 != ''" |
256+
| **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 != ''" |
257257
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
258258
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages |
259259
| **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
351351
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
352352
| **className** | `string` | optional | CSS class names |
353353
| **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) |
354-
| **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 != ''" |
354+
| **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 != ''" |
355355
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
356356
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages |
357357
| **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. |

packages/spec/src/ui/action.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ export const ActionParamSchema = lazySchema(() => strictObject(
414414
),
415415
/**
416416
* Visibility predicate (CEL) — same scope as the action-level `visible`
417-
* (`current_user` / `app` / `data` / `features`). When it evaluates false the
417+
* (`current_user` / `data` / `features`). When it evaluates false the
418418
* dialog omits this param entirely. Use it to hide a param that the backend
419419
* only accepts under an opt-in capability, e.g. the create-user `phoneNumber`
420420
* param gated on `features.phoneNumber` so the form never offers a field the
@@ -1297,7 +1297,7 @@ const actionObject = () => strictObject({
12971297
/**
12981298
* Whether the action is offered at all. Three arms, one meaning — see
12991299
* {@link ActionConditionInputSchema}: `false` parks the action, `true` is the
1300-
* explicit default, and a predicate gates it per record/user/app/features.
1300+
* explicit default, and a predicate gates it per record/user/features.
13011301
*
13021302
* ⚠️ Client-side hiding is UX, not authorization — the button is gone, the
13031303
* route is not. An action gated for access-control reasons must also be

packages/spec/src/ui/component.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,10 +719,10 @@ export const PageTabsProps = strictObject({
719719
* binds it to the data-source ADAPTER. Same key, two meanings.
720720
* * **The row's bare fields are spread flat**, so `status` resolves as
721721
* well as `record.status`. The ambient scope is spread AFTER the row,
722-
* so an ambient root (`app`, `features`, `user`, …) wins over a record
722+
* so an ambient root (`features`, `user`, …) wins over a record
723723
* field of the same name.
724724
*
725-
* Like the component-node surface it also mounts the ambient `app` /
725+
* Like the component-node surface it also mounts the ambient
726726
* `features` / `os.user` roots, which no ADR rules for a UI predicate
727727
* (ADR-0068's Non-goals: "only the user object is in scope here").
728728
*
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#17203] `app` is NOT an expression-scope root, and no prose face of the UI
5+
* schemas may say it is.
6+
*
7+
* ## The fact being pinned
8+
*
9+
* `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and
10+
* ADR-0068 has never ruled it. Decision batch #67 (2026-09-07) ruled option B
11+
* — the engine's `SCOPE_ROOTS` is the contract and the renderer aligns to it —
12+
* and ObjectUI shipped that: `buildExpressionScope` no longer binds `app`. The
13+
* producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was
14+
* closed `not_planned` in the same ruling.
15+
*
16+
* So every sentence in this package that told an author `app` is a root the
17+
* renderer mounts was describing a binding that no longer exists — and it was
18+
* the LAST surface anywhere that could still teach an author, or a
19+
* metadata-generating agent (ADR-0033 lists AI as a primary consumer of these
20+
* `.describe()` strings), to write `app.tier == 'pro'`.
21+
*
22+
* ## Why that mattered enough to pin
23+
*
24+
* The resulting predicate does not fail uniformly, and it is silent either
25+
* way: a field `visibleWhen` and a nav / area `visible` fail **OPEN** (the
26+
* gate stops hiding), while a conditional-formatting `condition` and a
27+
* row-action `visible` / `disabled` fail **CLOSED** (the rule silently stops
28+
* matching). An author sees nothing but a console line.
29+
*
30+
* ## The six faces
31+
*
32+
* Two of them are published — `.describe()` text reaches authoring tools and
33+
* is republished verbatim into `content/docs/references/ui/page.mdx` by
34+
* `build-docs.ts`. The other four are TSDoc, which no generator reads, so they
35+
* are seen only by whoever opens the file — often an AI author. That is
36+
* exactly why the first probe of this class missed some of them, and why the
37+
* pin covers both kinds.
38+
*
39+
* ⛔ **Scope: the claim, not the wording.** Rephrasing these sentences,
40+
* reordering the surviving roots, or documenting a root that genuinely gets
41+
* bound later is free. Re-introducing `app` into a scope-root enumeration on
42+
* any of these faces is not.
43+
*
44+
* ⛔ This file must NOT restate which roots `SCOPE_ROOTS` declares — that list
45+
* is `@objectstack/formula`'s, tested there. The assertions below are about
46+
* what these six sentences claim, which is a fact about this package's text.
47+
*/
48+
49+
import fs from 'node:fs';
50+
import path from 'node:path';
51+
import url from 'node:url';
52+
53+
import { describe, it, expect } from 'vitest';
54+
55+
import { PageComponentSchema } from './page.zod';
56+
57+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
58+
const read = (f: string) => fs.readFileSync(path.resolve(HERE, f), 'utf8');
59+
60+
const pageSource = read('page.zod.ts');
61+
const actionSource = read('action.zod.ts');
62+
const componentSource = read('component.zod.ts');
63+
64+
/**
65+
* The `app` token in a SCOPE-ROOT position — never the `app` metadata type,
66+
* which is a different word that legitimately appears all over these files
67+
* (`app` vs `utility` page types, `app.branding`, the `app` package type…).
68+
*
69+
* A bare /app/ search over any of these files matches dozens of those and is
70+
* therefore not a reading. Each assertion below is scoped to ONE sentence,
71+
* located by an anchor that survives rewording of everything around it.
72+
*/
73+
const sentenceContaining = (source: string, anchor: string): string => {
74+
const at = source.indexOf(anchor);
75+
expect(at, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeGreaterThan(-1);
76+
// The docblock sentence: from the anchor to the next period that ends it.
77+
const tail = source.slice(at, at + 400);
78+
return tail.replace(/\n\s*\*\s?/g, ' ');
79+
};
80+
81+
/** Root tokens that are still true on these surfaces and must stay in place. */
82+
const SURVIVING_ROOTS = ['features', 'os.user'] as const;
83+
84+
describe('#17203 — no UI prose face advertises `app` as an expression-scope root', () => {
85+
describe('published faces (read by authoring tools and republished into the reference docs)', () => {
86+
it('`PageComponentSchema.visibleWhen`.describe() does not name `app` among the mounted roots', () => {
87+
// ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)`
88+
// pipe (see `lazySchema`'s docblock), so it is a ZodPipe and the object
89+
// with the property descriptions is its INPUT side. Reaching for `.shape`
90+
// here yields `undefined` and every assertion below would then throw
91+
// rather than measure.
92+
const shape = (PageComponentSchema as unknown as {
93+
def: { in: { shape: Record<string, { description?: string }> } };
94+
}).def.in.shape;
95+
const description = shape.visibleWhen.description;
96+
97+
expect(description, 'the describe() must exist — this pin is about its content').toBeTruthy();
98+
const mounts = description!.slice(description!.indexOf('additionally mounts'));
99+
100+
// The claim: whatever this sentence says the renderer mounts, `app` is not in it.
101+
expect(mounts).not.toMatch(/`app`/);
102+
103+
// Survival controls — deleting the token must not have taken the sentence with it.
104+
for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root);
105+
expect(mounts).toContain('`data`');
106+
expect(mounts).toContain('NOT contract-guaranteed');
107+
108+
// Contract-bound roots are a different clause and are untouched.
109+
expect(description).toContain('`record`');
110+
expect(description).toContain('`current_user`');
111+
});
112+
});
113+
114+
describe('TSDoc faces (no generator reads these — an AI author opening the file does)', () => {
115+
it('page.zod.ts — the "Ambient roots" docblock', () => {
116+
const s = sentenceContaining(pageSource, 'The shipping renderer additionally mounts');
117+
118+
expect(s).not.toMatch(/`app`/);
119+
for (const root of SURVIVING_ROOTS) expect(s).toContain(root);
120+
expect(s).toContain('binds `data`');
121+
});
122+
123+
it('action.zod.ts — the param-level `visible` scope list', () => {
124+
const s = sentenceContaining(actionSource, 'same scope as the action-level');
125+
126+
expect(s).not.toMatch(/`app`/);
127+
expect(s).toContain('`current_user`');
128+
expect(s).toContain('`data`');
129+
expect(s).toContain('`features`');
130+
});
131+
132+
it('action.zod.ts — the action-level `visible` scope list, stated unbackticked', () => {
133+
// This face states the same claim in different words — `record/user/app/features`,
134+
// no backticks. A probe shaped for the backticked token could not see it.
135+
const s = sentenceContaining(actionSource, 'a predicate gates it per');
136+
137+
expect(s).not.toMatch(/\bapp\b/);
138+
expect(s).toContain('record/user/features');
139+
});
140+
141+
it('component.zod.ts — the ambient-root name-resolution example', () => {
142+
const s = sentenceContaining(componentSource, 'so an ambient root (');
143+
144+
expect(s).not.toMatch(/`app`/);
145+
expect(s).toContain('`features`');
146+
expect(s).toContain('`user`');
147+
});
148+
149+
it('component.zod.ts — the page:tabs "also mounts the ambient …" sentence', () => {
150+
const s = sentenceContaining(componentSource, 'it also mounts the ambient');
151+
152+
expect(s).not.toMatch(/`app`/);
153+
for (const root of SURVIVING_ROOTS) expect(s).toContain(root);
154+
});
155+
});
156+
157+
describe('probe controls — a zero above is only a reading if these hold', () => {
158+
/**
159+
* LIT. `page.zod.ts` still says `app` twice, both times about the page
160+
* TYPE (`app` vs `utility` vs `blank`) — a different word that this card
161+
* deliberately did NOT touch. It is the standing proof that a bare /app/
162+
* probe over this file cannot answer the scope-root question, and that the
163+
* anchored, sentence-scoped assertions above are the required shape.
164+
*
165+
* If this ever reads 0, someone deleted the page-type prose and the
166+
* assertions above quietly became unable to distinguish a real regression
167+
* from a file that simply stopped using the word.
168+
*/
169+
it('LIT: the `app` page TYPE prose survives, so a scoped probe is still required', () => {
170+
expect(pageSource).toContain('`app` is an app-level page');
171+
// THREE occurrences on TWO lines — `grep -c` answers lines and reads 2,
172+
// which is the whole reason this is asserted on occurrences instead.
173+
expect(pageSource.match(/`app`/g) ?? []).toHaveLength(3);
174+
});
175+
176+
/**
177+
* DARK. A fabricated token, which must read absent everywhere. It proves
178+
* the `not.toMatch` / `not.toContain` arms above are wired to something
179+
* that can actually fail, rather than passing on an empty haystack.
180+
*/
181+
it('DARK: a fabricated root token reads absent on every face', () => {
182+
for (const source of [pageSource, actionSource, componentSource]) {
183+
expect(source).not.toContain('`appzz_scope_root`');
184+
}
185+
});
186+
187+
/**
188+
* The other half of the dark control: the helper must throw when its
189+
* anchor is gone, so a site that gets renamed out from under this pin
190+
* fails loudly instead of asserting over an empty string.
191+
*/
192+
it('DARK: a missing anchor fails the pin rather than passing vacuously', () => {
193+
expect(() => sentenceContaining(pageSource, 'no such anchor exists in this file')).toThrow();
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)