From de0fc27373a4193879bdb51bb940161f7d7bbfac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:07:29 +0000 Subject: [PATCH 1/4] fix(app-shell): unbind the ambient `data` root on record surfaces (#8166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/formula`'s `SCOPE_ROOTS` contains `data`, so at `scope: 'record'` the authoring lint accepts `data.status == 'x'`. objectui#5741 (Phase 2 of the objectui#5330 canon) retired `data.*` on runtime record surfaces — the row is `record.*` and nothing else — but `buildExpressionScope` kept binding an ambient `data`, so a predicate the linter had waved through also RESOLVED at runtime, against that bag rather than against the row. Measured on `origin/main`, one authored `visibleWhen: "data.status == 'x'"` meant three different things: `[runtime] No such key: status` against the `{}` every `ExpressionProvider` mount passes; a SILENT resolution off the record under edit on `AppContent`'s field-list evaluator in edit mode (`data: editingRecord`); and back to the first case in create mode. `buildExpressionScope` no longer accepts or binds `data`, and the two imperative call sites stop passing one. All three legs collapse onto the engine's own verdict, `[type] Unknown variable: data` — the same shape `app` has produced since objectui#8155, and the verdict the server gives the same string. The fault is loud, not fatal: the fallback directions are untouched (`visibleWhen` fails open, `readonlyWhen`/`requiredWhen` fail permissive), so nothing throws mid-render. `record.*` is unaffected, and metadata-admin's own `data` = draft binding is unaffected by construction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .changeset/8166-record-scope-data-root.md | 53 ++++ packages/app-shell/src/console/AppContent.tsx | 14 +- .../ExpressionProvider.predicateScope.test.ts | 15 +- .../src/providers/ExpressionProvider.tsx | 86 ++++-- .../recordScopeDataRoot-8166.render.test.tsx | 223 ++++++++++++++++ .../recordScopeDataRoot-8166.test.ts | 247 ++++++++++++++++++ .../app-shell/src/views/RecordFormPage.tsx | 5 +- .../react/src/utils/visibilityDiagnostic.ts | 14 +- 8 files changed, 630 insertions(+), 27 deletions(-) create mode 100644 .changeset/8166-record-scope-data-root.md create mode 100644 packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.render.test.tsx create mode 100644 packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.test.ts diff --git a/.changeset/8166-record-scope-data-root.md b/.changeset/8166-record-scope-data-root.md new file mode 100644 index 0000000000..5a4723d020 --- /dev/null +++ b/.changeset/8166-record-scope-data-root.md @@ -0,0 +1,53 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/react': patch +--- + +Stop binding an ambient `data` root on record surfaces, so a `data.*` predicate faults +loudly instead of resolving against the host's bag (objectui#8166, ruled 2026-09-10). + +**User-visible behaviour change, at RUNTIME and at DEBUG time — not at authoring time.** +Nothing an author sees while writing a predicate moves: the authoring lint's accept set +lives in `@objectstack/formula`'s `SCOPE_ROOTS`, which still contains `data`, and +`data.status == 'x'` still lints clean at `scope: 'record'`. Splitting that list per +scope is the producer-side half and is not this repo's to make. What changes is what +the runtime does with such a predicate once it is saved. + +**What was wrong.** objectui#5741 (Phase 2 of the objectui#5330 canon) retired `data.*` +on runtime record surfaces — the row is bound as `record.*` and nothing else. But +`@object-ui/app-shell`'s `buildExpressionScope` kept binding an ambient `data`, so a +predicate the linter had waved through also *resolved* at runtime, against that bag +rather than against the row. Measured on `main` before this change, one authored +`visibleWhen: "data.status == 'x'"` meant three different things: + +- on every `ExpressionProvider` mount and on `RecordFormPage`'s own evaluator, the + ambient `data` was `{}`, so the engine answered `[runtime] No such key: status` — a + fault, warned once, and the field-rule fallback applied (fail-open for `visibleWhen`); +- on `AppContent`'s field-list evaluator for the global record-form modal in EDIT mode + the ambient `data` was **the record being edited**, so the predicate resolved, with no + diagnostic at all, off the wrong layer's object — an author testing there would have + seen it "work"; +- in CREATE mode on that same modal it fell back to the first case. + +**What changes.** `buildExpressionScope` no longer accepts or binds `data`, and the two +imperative call sites (`AppContent`, `RecordFormPage`) stop passing one. A `data.*` +predicate on any app-shell surface now produces the engine's own verdict — `[type] +Unknown variable: data` — on both diagnostic channels (the one-time `console.warn` and +the `onFault` passback), which is the same verdict the server gives the same string and +the same shape `app` has produced since objectui#8155. + +**The fault is loud, not fatal.** The verdict a faulting predicate resolves to is +unchanged: `visibleWhen` still fails OPEN, `readonlyWhen` / `requiredWhen` still fail +permissive (`@object-ui/core`'s `fieldRules.ts`; the direction is objectui#8069's open +question, not this change's). So a record form renders exactly as before except that the +console now names the root. Nothing throws. + +**Migration.** Rewrite `data.foo` as `record.foo` on any runtime record surface — the +canonical spelling since objectui#5741, and the only one that reaches the row. The +metadata-admin designer is unaffected: its `data` is the DRAFT under edit (ADR-0089 D3, +`CANONICAL_ROOT_BY_LAYER` = `{ runtime: 'record', metadata: 'data' }`), bound by +`views/metadata-admin/predicate.ts` through its own builder, which takes only the +identity roots from this bag and assigns its own `data` last. + +`@object-ui/react` carries a docblock correction only: the `app-shell` tier paragraph in +`utils/visibilityDiagnostic.ts` described the bag this change edits. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 67e7cbd9fd..9adb9a9055 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -658,12 +658,22 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const expressionEvaluator = useMemo( // ⛔ No `app`: objectui#8155 removed it from the predicate scope, because // neither ADR-0068 nor the engine's `SCOPE_ROOTS` declares such a root. + // + // ⛔ No `data` either: objectui#8166. This site passed `editingRecord` under + // that root, and it was the ONE mount in the repo where the ambient `data` + // was not empty — so an authored `visibleWhen: "data.status == 'x'"` + // RESOLVED here, silently, against the record being edited, while the SAME + // predicate on the SAME modal's per-field rules (form.tsx, over the + // provider's `data={{}}`) faulted, and the same predicate in CREATE mode + // (`editingRecord` null) faulted too. One authored string, three answers, + // none of them the row's canonical `record.*` binding (objectui#5741). + // The row reaches field predicates as `record`, from the form's own + // `ruleRecord` — not from this bag, which is why nothing is lost here. () => createExpressionEvaluator({ user: buildExpressionUser(user), - data: editingRecord || {}, features, }), - [user, editingRecord, features], + [user, features], ); // objectui#5619 — `isWorkspaceAdminResolved` belongs in this readiness gate diff --git a/packages/app-shell/src/providers/ExpressionProvider.predicateScope.test.ts b/packages/app-shell/src/providers/ExpressionProvider.predicateScope.test.ts index fa87c707fa..90a1128d60 100644 --- a/packages/app-shell/src/providers/ExpressionProvider.predicateScope.test.ts +++ b/packages/app-shell/src/providers/ExpressionProvider.predicateScope.test.ts @@ -70,7 +70,7 @@ describe('objectui#6493 — buildExpressionScope binds one user object under all expect(scope.os.user).toBe(user); }); - it('binds data and features — and NOT `app` — defaulting every root to an empty object', () => { + it('binds features — and NOT `app`, NOT `data` — defaulting every root to an empty object', () => { const scope = buildExpressionScope(); // ⛔ No `app` (objectui#8155, ruled 2026-09-07). Neither ADR-0068 nor // `@objectstack/formula`'s SCOPE_ROOTS declares such a root, so binding it @@ -78,11 +78,18 @@ describe('objectui#6493 — buildExpressionScope binds one user object under all // conditional-formatting editor and refused by the linter judging the very // same field, with no spelling that did both. // + // ⛔ No `data` (objectui#8166, ruled 2026-09-10) — the mirror case. The + // engine ACCEPTS `data` at `scope: 'record'`, which is precisely why an + // ambient binding here was worse than `app`'s: the lint stayed green and + // the predicate resolved, against this bag instead of against the row that + // objectui#5741 made `record.*`. Unbound, it faults with the engine's own + // `Unknown variable: data`. + // // `toStrictEqual` is what makes this a fence rather than a sample — a root - // added BACK reddens here just as loudly as one removed, and `app` - // returning to this bag is the drift the ruling is guarding against. + // added BACK reddens here just as loudly as one removed, and `app` or + // `data` returning to this bag is the drift the rulings guard against. expect(scope).toStrictEqual({ - current_user: {}, user: {}, ctx: { user: {} }, os: { user: {} }, data: {}, features: {}, + current_user: {}, user: {}, ctx: { user: {} }, os: { user: {} }, features: {}, }); // The identity above holds for the defaults too — the hand-written fallback // in `useExpressionContext` used to mint three separate empty objects. diff --git a/packages/app-shell/src/providers/ExpressionProvider.tsx b/packages/app-shell/src/providers/ExpressionProvider.tsx index d48cc34b68..8f8400dee5 100644 --- a/packages/app-shell/src/providers/ExpressionProvider.tsx +++ b/packages/app-shell/src/providers/ExpressionProvider.tsx @@ -50,8 +50,14 @@ export interface ExpressionScopeInput { * ruling exists to remove, so the parameter is gone rather than ignored. * `ExpressionProvider` still takes an `app` prop and still publishes it on * the React context value; that is a different thing from a CEL root. + * + * ⛔ No `data` either, and for the mirror-image reason — objectui#8166, + * ruled 2026-09-10. `app` was BOUND HERE AND REFUSED by the engine; `data` + * was BOUND HERE AND ACCEPTED by it, while naming something that is not the + * record. Same remedy, same argument: the builder no longer takes an + * argument it must not bind. `ExpressionProvider` still takes a `data` prop + * and still publishes it on the React context value. */ - data?: Record; features?: Record; } @@ -107,15 +113,64 @@ export interface ExpressionScopeInput { * diagnostic in `celAuthoring.ts`: that is the lenient-fallback shape * AGENTS.md #0.1 bans. * - * Every root below is one the engine accepts, so the three surfaces — what - * this binds, what the editor advertises, what the linter admits — now agree. + * ## Why there is no `data` root either (objectui#8166, ruled 2026-09-10) + * + * `app` and `data` came off the same list producing OPPOSITE failures, and the + * `data` half is the nastier one. + * + * objectui#5741 (Phase 2 of the objectui#5330 canon) retired `data.*` on + * runtime record surfaces: the row is bound as `record.*` and nothing else, and + * `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` records the server's + * verdict for the retired spelling — `data.status` is `❌ Unknown variable: + * data`. But `@objectstack/formula`'s `SCOPE_ROOTS` still contains `data`, so + * at `scope: 'record'` the AUTHORING LINT accepts `data.status == 'x'`. An + * ambient `data` bound HERE is what let that accepted-by-the-linter predicate + * also resolve at runtime — against this bag rather than against the row. + * + * Measured on `origin/main` before the removal, one authored + * `visibleWhen: "data.status == 'x'"` meant three different things depending on + * which of this tier's bags reached it: + * + * - against `data: {}` (what every `ExpressionProvider` mount passes, and + * what `RecordFormPage`'s own evaluator built) the engine answered + * `[runtime] No such key: status` — a fault, so the field-rule fallback + * applied: fail-OPEN for `visibleWhen`; + * - against `data: editingRecord` (what `AppContent`'s field-list evaluator + * built for the global record-form modal, in EDIT mode) it RESOLVED, with + * no diagnostic at all — the wrong-layer root silently answering from the + * host's record; + * - in CREATE mode on that same modal `editingRecord` is null, so the same + * predicate fell back to the first case. + * + * A root that answers three ways and is never the row is not a root. Removing + * it collapses all three onto the engine's own verdict — `[type] Unknown + * variable: data`, byte-identical in shape to the `app` diagnostic above — and + * `record.*`, the canon, is unaffected. + * + * ⛔ Two routes the ruling refused. De-advertising `data` from an + * advertised-roots list fixes nothing: it stops autocomplete RECOMMENDING the + * root while the lint still ACCEPTS it, so every already-authored `data.*` + * predicate stays green and stays wrong. And ⛔ filtering the diagnostic in + * `celAuthoring.ts` is treating the wrong layer — there is no diagnostic to + * filter, the absence of one is the defect. Splitting `SCOPE_ROOTS` per scope + * is the producer-side half and lives in `@objectstack/formula`, not here. + * + * The one `data` that survives this tier is the metadata-admin form's, and it + * is a different object one layer up: `views/metadata-admin/predicate.ts` + * binds `data` = the DRAFT under edit through its own builder (ADR-0089 D3, + * `CANONICAL_ROOT_BY_LAYER` = `{ runtime: 'record', metadata: 'data' }`), takes + * only the identity roots from this bag, and assigns its own `data` last. It is + * unaffected by this removal, by construction. + * + * Every root below is one the engine accepts AND one this tier can actually + * answer, so the three surfaces — what this binds, what the editor advertises, + * what the linter admits — now agree. */ export function buildExpressionScope({ user = {}, - data = {}, features = {}, }: ExpressionScopeInput = {}): Record { - return { current_user: user, user, ctx: { user }, os: { user }, data, features }; + return { current_user: user, user, ctx: { user }, os: { user }, features }; } /** @@ -140,9 +195,10 @@ interface ExpressionProviderProps { export function ExpressionProvider({ children, user = {}, app = {}, data = {}, features = {} }: ExpressionProviderProps) { const value = useMemo(() => { - const evaluator = createExpressionEvaluator({ user, data, features }); - // `app` is still published on the context value — `DashboardView` reads it - // as a plain value. It is NOT handed to the evaluator: objectui#8155. + const evaluator = createExpressionEvaluator({ user, features }); + // `app` and `data` are still published on the context value — `DashboardView` + // reads `app` as a plain value. Neither is handed to the evaluator: + // objectui#8155 (`app`), objectui#8166 (`data`). return { user, app, data, features, evaluator }; }, [user, app, data, features]); @@ -152,8 +208,8 @@ export function ExpressionProvider({ children, user = {}, app = {}, data = {}, f // The SAME bag the evaluator above got — one builder, so the imperative and // the hook-driven halves of this provider cannot drift apart either. const scope = useMemo( - () => buildExpressionScope({ user, data, features }), - [user, data, features], + () => buildExpressionScope({ user, features }), + [user, features], ); return ( @@ -176,14 +232,14 @@ export function useExpressionContext(): ExpressionContextValue { // spells as aliases "pointing at the same object". // // The scope input and the context value are no longer the same object: - // `app` is a readable context FIELD but not a CEL root (objectui#8155), so - // handing this bag straight to the builder would smuggle back the very - // binding the ruling removed. + // `app` (objectui#8155) and `data` (objectui#8166) are readable context + // FIELDS but not CEL roots, so handing this bag straight to the builder + // would smuggle back the very bindings those rulings removed. // Left UNANNOTATED on purpose: annotating it `ExpressionScopeInput` widens // every member to optional, and the spread below then fails to satisfy // `ExpressionContextValue`, whose members are required. - const scope = { user: {}, data: {}, features: {} }; - return { ...scope, app: {}, evaluator: createExpressionEvaluator(scope) }; + const scope = { user: {}, features: {} }; + return { ...scope, app: {}, data: {}, evaluator: createExpressionEvaluator(scope) }; } return ctx; } diff --git a/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.render.test.tsx b/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.render.test.tsx new file mode 100644 index 0000000000..af2b15247f --- /dev/null +++ b/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.render.test.tsx @@ -0,0 +1,223 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8166, the PAGE-level half — measured on a real record surface. + * + * `recordScopeDataRoot-8166.test.ts` pins the bag and the engine verdict. + * This file renders `RecordFormPage`, which is a record surface in the card's + * own sense, and asserts the two things a unit test on the builder cannot + * reach: + * + * 1. ⭐ **The form does not CRASH.** This is the hard precondition the ruling + * was made under: "make it fault" must not mean "make it throw mid-render", + * because a record form that throws is worse than one that silently hides a + * field. Every fault channel on this path is fail-soft, and that is a + * property of the code, not of a promise — so it is asserted here, against a + * page rendering a `data.*`-gated field. + * 2. **The bag the page PUBLISHES to its descendants carries no `data`.** The + * probe sits inside the page's own `ExpressionProvider`, so it reads what + * `form.tsx` reads through `usePredicateScope()` — the actual chain the card + * draws, not a reconstruction of it. + * + * The harness (probe + mocks + fixture shape) is + * `expressionUser.mountParity.test.tsx`'s, which measures the same provider on + * the same page for the identity roots. + * + * ## Reverse verification (direction predicted BEFORE running) + * + * Restore `data` to `buildExpressionScope` (and the `data:` argument at this + * page's own `createExpressionEvaluator` call): + * - `publishes a predicate scope with no data root` goes RED — the key is + * back; + * - `a data.* gate is REPORTED` goes RED, and in the informative way: the + * reported reason reverts from `Unknown variable: data` to + * `No such key: status`, i.e. the page keeps warning but about the wrong + * thing; + * - `does not throw` stays GREEN both ways — it is the precondition, and a + * precondition that only holds after the change would not be one. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +// Not on the `@object-ui/react` barrel. The vitest alias maps that barrel to +// `packages/react/src`, so this deep path is the SAME module instance the +// `ExpressionProvider` under test publishes through. +import { usePredicateScope } from '../../../../react/src/hooks/useExpression'; +// The reporter's dedupe `Set` is MODULE state shared across this file's tests. +// Without the reset, the first test to render registers every line and every +// later test reads silence — "a green run that checked nothing", in that +// module's own words. +import { __resetVisibilityPredicateWarnings } from '../../../../react/src/utils/visibilityDiagnostic'; +import { RecordFormPage } from '../../views/RecordFormPage'; + +const h = React.createElement; + +const { publishedScopes, formSchemas, getAuthConfig, authState } = vi.hoisted(() => ({ + publishedScopes: [] as Record[], + formSchemas: [] as any[], + getAuthConfig: vi.fn(async () => ({ features: {} as Record })), + authState: { user: null as Record | null }, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + get user() { return authState.user; }, + getAuthConfig, + activeOrganization: null, + }), +})); + +/** The probe — stands in for `ObjectForm`, the page's only child inside its provider. */ +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: ({ schema }: any) => { + formSchemas.push(schema); + publishedScopes.push(usePredicateScope()); + return h('div', { 'data-testid': 'object-form' }); + }, +})); + +const metadataState = { objects: [] as any[], loading: false }; +vi.mock('../MetadataProvider', () => ({ useMetadata: () => metadataState })); +vi.mock('../AdapterProvider', () => ({ useAdapter: () => null })); + +/** The served shape: `ExpressionInputSchema` normalises authored strings into this. */ +const cel = (source: string) => ({ dialect: 'cel', source }); + +const CONTACTS = { + name: 'contacts', + label: 'Contacts', + fields: { + name: { type: 'text' }, + // ⭐ The card's predicate, authored exactly as an author would write it + // after the lint waved it through at `scope: 'record'`. + wrong_layer: { type: 'text', visibleWhen: cel("data.status == 'x'") }, + // The positive control (objectui#8155): the root this tier already refuses. + app_gated: { type: 'text', visibleWhen: cel("app.name == 'crm'") }, + // The regression floor — an identity gate this page DOES bind, and one the + // signed-in fixture fails, so it must still HIDE. + admin_only: { type: 'text', visibleWhen: cel('ctx.user.isPlatformAdmin == true') }, + }, +}; + +const CLERK = { + id: 'u_clerk', name: 'Bo', email: 'bo@example.com', role: 'user', + positions: ['sales_clerk'], +}; + +function renderPage() { + return render( + h(I18nProvider, { + config: { defaultLanguage: 'en', detectBrowserLanguage: false }, + children: h( + MemoryRouter, + { initialEntries: ['/apps/crm/contacts/new'] }, + h(Routes, null, h(Route, { + path: '/apps/:appName/:objectName/new', + element: h(RecordFormPage, { mode: 'create' }), + })), + ), + }), + ); +} + +const lastScope = (): Record => publishedScopes[publishedScopes.length - 1]; +const lastFields = (): string[] => formSchemas[formSchemas.length - 1].fields; + +let warnings: string[]; + +beforeEach(() => { + metadataState.objects = [CONTACTS]; + metadataState.loading = false; + authState.user = CLERK; + getAuthConfig.mockResolvedValue({ features: {} }); + publishedScopes.length = 0; + formSchemas.length = 0; + warnings = []; + __resetVisibilityPredicateWarnings(); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => void warnings.push(a.join(' '))); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectui#8166 — the record form page, rendered', () => { + it('⭐ does not throw while rendering a `data.*`-gated field (the ZONE-2 precondition)', async () => { + // `render` rethrows anything the tree throws, so a crash fails here rather + // than surfacing as an unrelated assertion further down. + expect(() => renderPage()).not.toThrow(); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + // And it renders the form, rather than an error boundary's fallback. + expect(document.querySelector('[data-testid="object-form"]')).not.toBeNull(); + }); + + it('publishes a predicate scope with no `data` root', async () => { + renderPage(); + await waitFor(() => expect(publishedScopes.length).toBeGreaterThan(0)); + + const scope = lastScope(); + expect(scope).not.toHaveProperty('data'); + // ⛔ and still no `app` (objectui#8155) — the sibling this card mirrors. + expect(scope).not.toHaveProperty('app'); + // The roots that remain, so this is a fence and not a one-sided deletion. + expect(scope).toHaveProperty('current_user'); + expect(scope).toHaveProperty('features'); + expect(scope.os.user).toBe(scope.user); + }); + + it('REPORTS the `data.*` gate — the same channel the `app` control uses', async () => { + renderPage(); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + + const dataLine = warnings.find((w) => w.includes("data.status == 'x'")); + const appLine = warnings.find((w) => w.includes("app.name == 'crm'")); + + // The control first: this is what "loud" already looked like on this + // surface, and it is the shape the `data` line has to match. + expect(appLine).toBeDefined(); + expect(appLine).toContain('Unknown variable: app'); + + expect(dataLine).toBeDefined(); + expect(dataLine).toContain('Unknown variable: data'); + // The author's locator: the predicate SOURCE and the key they wrote. + expect(dataLine).toContain('visibleWhen'); + }); + + it('REGRESSION FLOOR — a correct identity gate still hides, an ungated field still shows', async () => { + renderPage(); + await waitFor(() => expect(formSchemas.length).toBeGreaterThan(0)); + + const fields = lastFields(); + // The floor: the gate that CAN resolve still bites, and a clerk is not a + // platform admin. If this ever reads `toContain`, the change broke working + // predicates and the card's whole point with it. + expect(fields).not.toContain('admin_only'); + // Ungated fields are untouched. + expect(fields).toContain('name'); + // And the faulting ones fail OPEN, which is this tier's shipped, documented + // posture (objectui#6443 / #6487) and NOT this card's to flip: the field is + // on screen and the console says why. Asserted so the behaviour is a stated + // outcome rather than an accident nobody wrote down. + expect(fields).toContain('wrong_layer'); + expect(fields).toContain('app_gated'); + }); +}); diff --git a/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.test.ts b/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.test.ts new file mode 100644 index 0000000000..8f05846edf --- /dev/null +++ b/packages/app-shell/src/providers/__tests__/recordScopeDataRoot-8166.test.ts @@ -0,0 +1,247 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8166 — a `data.*` predicate at RECORD scope must fail LOUDLY. + * + * ## What was wrong, and why a green suite proved nothing + * + * `@objectstack/formula`'s `SCOPE_ROOTS` contains `data`, so at + * `scope: 'record'` the authoring lint ACCEPTS `data.status == 'x'`. But + * objectui#5741 (Phase 2 of the objectui#5330 canon) retired `data.*` on + * runtime record surfaces — the row is `record.*` and nothing else, and + * `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` records the server's + * verdict for the retired spelling as `❌ Unknown variable: data`. + * + * `buildExpressionScope` bound an ambient `data` anyway, which is what let a + * predicate the linter had waved through also RESOLVE at runtime — against + * that bag rather than against the row. Measured on `origin/main` before this + * change, ONE authored `visibleWhen: "data.status == 'x'"` meant three things: + * + * | mount | ambient `data` | engine verdict | + * | :-- | :-- | :-- | + * | `RecordFormPage` / every `ExpressionProvider` mount | `{}` | `[runtime] No such key: status` → fail-open | + * | `AppContent` field-list evaluator, EDIT mode | `editingRecord` | RESOLVED, silently, off the host's record | + * | `AppContent` field-list evaluator, CREATE mode | `{}` | `[runtime] No such key: status` → fail-open | + * + * The middle row is the one the card is about: no error, no warning, and an + * answer that came from the wrong object. The tests below pin all of it — + * including the row that was ALREADY loud, so a future reader can tell the two + * failure shapes apart rather than reading "it warns" as the whole story. + * + * ## The chain these assertions stand on + * + * `evalFieldPredicate(pred, ruleRecord, fallback, previousRecord, scope, …)` is + * the call `packages/components/src/renderers/form/form.tsx` makes for every + * `visibleWhen` / `readonlyWhen` / `requiredWhen` on a record form, with + * `scope` coming from `usePredicateScope()` — the bag `ExpressionProvider` + * publishes through `PredicateScopeProvider`, i.e. `buildExpressionScope`'s + * return value. So passing that bag to that function IS the record-surface + * path, not a model of it. + * + * ## The positive control, in the same run + * + * `app` (objectui#8155) is the root this tier already unbound, and it is what + * "loud" looks like here. Every `data` assertion below has an `app` twin, so a + * change that silences this channel cannot pass by silencing only the new half. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { evalFieldPredicate } from '@object-ui/core'; +import { buildExpressionScope } from '../ExpressionProvider'; + +/** The signed-in user, the one root this tier is actually about. */ +const USER = { id: 'u1', name: 'Ada', positions: ['sales_manager'] }; + +/** The row a record form is editing — `status` is deliberately a REAL key. */ +const ROW = { id: 'r1', status: 'x', amount: 100 }; + +/** + * One `evalFieldPredicate` call shaped exactly like `form.tsx`'s, with both + * diagnostic channels captured. + * + * `warn: false` is NOT passed: the built-in `console.warn` is half of what + * "loud" means on this surface, and a test that only watched `onFault` would + * stay green if the warning were removed. + */ +function evalOnRecordSurface( + predicate: string, + fallback: boolean, + scope: Record, +): { verdict: boolean; faults: string[]; warnings: string[]; threw: unknown } { + const faults: string[] = []; + const warnings: string[] = []; + const spy = vi + .spyOn(console, 'warn') + .mockImplementation((...args: unknown[]) => void warnings.push(args.join(' '))); + let verdict = fallback; + let threw: unknown; + try { + verdict = evalFieldPredicate(predicate, ROW, fallback, undefined, scope, { + context: `visibleWhen of field 'demo'`, + onFault: (reason) => faults.push(reason), + }); + } catch (err) { + threw = err; + } finally { + spy.mockRestore(); + } + return { verdict, faults, warnings, threw }; +} + +beforeEach(() => { + // `evalFieldPredicate` dedupes its built-in warning per predicate TEXT in a + // module-level Set, so every predicate string below is unique. Resetting + // modules per test would be the alternative and would cost a re-import of + // the CEL engine for each one. + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('objectui#8166 — the record-scope predicate bag binds no `data` root', () => { + it('has no `data` key at all, on any input shape', () => { + expect(buildExpressionScope({ user: USER })).not.toHaveProperty('data'); + expect(buildExpressionScope()).not.toHaveProperty('data'); + // A caller that still tries to hand one in cannot put it back: the builder + // destructures the roots it binds and ignores everything else. This is the + // half that makes the removal a FENCE rather than a tidy-up — `AppContent` + // passed `data: editingRecord` here until this card. + expect( + buildExpressionScope({ user: USER, data: ROW } as Parameters[0]), + ).not.toHaveProperty('data'); + }); + + it('still binds the identity aliases and `features` — nothing else moved', () => { + const scope = buildExpressionScope({ user: USER }); + expect(scope.current_user).toBe(USER); + expect(scope.user).toBe(USER); + expect(scope.ctx.user).toBe(USER); + expect(scope.os.user).toBe(USER); + expect(scope.features).toEqual({}); + }); +}); + +describe('objectui#8166 — a `data.*` predicate at record scope is now LOUD', () => { + it('reports the engine\'s own `Unknown variable: data`, on BOTH channels', () => { + const scope = buildExpressionScope({ user: USER }); + const r = evalOnRecordSurface("data.status == 'x'", true, scope); + + // ⭐ The ZONE-2 hard precondition, pinned as an assertion rather than a + // memory: making this fault must not make a record form CRASH. A form that + // throws mid-render is worse than one that hides a field. + expect(r.threw).toBeUndefined(); + + expect(r.faults).toHaveLength(1); + expect(r.faults[0]).toContain('Unknown variable: data'); + // `[type]` is the engine's own classification for an unbound root — the + // same tag the `app` control below carries. Pinned because the PREVIOUS + // behaviour also produced a reason (`[runtime] No such key: status`), so + // "there is a reason" does not distinguish before from after; the KIND + // does. + expect(r.faults[0]).toContain('[type]'); + + // The built-in channel, which is what an author actually sees in a console. + expect(r.warnings).toHaveLength(1); + expect(r.warnings[0]).toContain('Unknown variable: data'); + expect(r.warnings[0]).toContain("data.status == 'x'"); + // The advice line names the root that DOES reach the row. + expect(r.warnings[0]).toContain("record."); + }); + + it('is loud in every fallback direction — readonlyWhen/requiredWhen too', () => { + const scope = buildExpressionScope({ user: USER }); + // `visibleWhen` faults fail OPEN (`true`), `readonlyWhen`/`requiredWhen` + // fail permissive (`false`). Both directions must REPORT; the verdict is + // the shipped fault policy (`@object-ui/core`'s `fieldRules.ts`) and this + // card does not move it — objectui#8069 owns that question. + const open = evalOnRecordSurface("data.status == 'ro'", true, scope); + const closed = evalOnRecordSurface("data.status == 'rq'", false, scope); + + expect(open.verdict).toBe(true); + expect(closed.verdict).toBe(false); + expect(open.faults[0]).toContain('Unknown variable: data'); + expect(closed.faults[0]).toContain('Unknown variable: data'); + }); + + it('POSITIVE CONTROL — `app` (objectui#8155) faults in exactly the same shape', () => { + const scope = buildExpressionScope({ user: USER }); + const app = evalOnRecordSurface("app.name == 'crm'", true, scope); + const data = evalOnRecordSurface("data.name == 'crm'", true, scope); + + expect(app.threw).toBeUndefined(); + expect(app.faults[0]).toContain('[type]'); + expect(app.faults[0]).toContain('Unknown variable: app'); + expect(app.warnings).toHaveLength(1); + + // The point of the control: `data` is now the same CLASS of failure as the + // root this tier already refused. The reason's FIRST LINE is the engine's + // classification — comparing those two, rather than two independent + // `toContain`s, is what makes this a parity measurement. (The lines after + // it are the source excerpt and a caret, which differ by construction + // because the two predicates are different strings.) + expect(data.faults[0].split('\n')[0]).toBe('[type] Unknown variable: data'); + expect(app.faults[0].split('\n')[0]).toBe('[type] Unknown variable: app'); + }); + + it('the silent leg is gone: the host record can no longer answer for the row', () => { + // The bag `AppContent` built until this card, reconstructed byte-for-byte: + // the shipped scope plus the record under edit bound as `data`. It is + // reproduced here — rather than imported — precisely because the code that + // built it is what this card deleted. + const preFixBag = { ...buildExpressionScope({ user: USER }), data: ROW }; + const before = evalOnRecordSurface('data.amount > 50', true, preFixBag); + // Silence, and a verdict computed off the HOST's object rather than the + // row the author meant: no fault, no warning, a confident answer. That is + // the defect — and note it is a TRUE here, so an author testing on this + // one mount would have seen the predicate "work". + expect(before.faults).toHaveLength(0); + expect(before.warnings).toHaveLength(0); + expect(before.verdict).toBe(true); + // Both polarities, so this witnesses a resolving predicate rather than a + // fallback that happens to agree. + const beforeFalse = evalOnRecordSurface('data.amount > 500', true, preFixBag); + expect(beforeFalse.faults).toHaveLength(0); + expect(beforeFalse.verdict).toBe(false); + + // The shipped bag cannot reach that state, because there is no `data` in it. + const after = evalOnRecordSurface("data.status == 'x2'", true, buildExpressionScope({ user: USER })); + expect(after.faults).toHaveLength(1); + expect(after.faults[0]).toContain('Unknown variable: data'); + }); +}); + +describe('objectui#8166 — REGRESSION FLOOR: a correct record predicate still gates', () => { + it('shows the field when the canonical `record.*` predicate holds', () => { + const r = evalOnRecordSurface("record.status == 'x'", true, buildExpressionScope({ user: USER })); + expect(r.verdict).toBe(true); + expect(r.faults).toHaveLength(0); + expect(r.warnings).toHaveLength(0); + }); + + it('HIDES the field when it does not — both polarities, or the gate never gated', () => { + const r = evalOnRecordSurface("record.status == 'archived'", true, buildExpressionScope({ user: USER })); + expect(r.verdict).toBe(false); + expect(r.faults).toHaveLength(0); + expect(r.warnings).toHaveLength(0); + }); + + it('an identity predicate still resolves through the shared bag', () => { + const scope = buildExpressionScope({ user: USER }); + expect(evalOnRecordSurface("'sales_manager' in current_user.positions", false, scope).verdict).toBe(true); + expect(evalOnRecordSurface("'sales_clerk' in os.user.positions", false, scope).verdict).toBe(false); + }); + + it('a numeric record predicate still discriminates — not just the string one', () => { + const scope = buildExpressionScope({ user: USER }); + expect(evalOnRecordSurface('record.amount > 50', false, scope).verdict).toBe(true); + expect(evalOnRecordSurface('record.amount > 500', true, scope).verdict).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index 58263be09e..a510f487b2 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -204,11 +204,14 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { () => // ⛔ No `app`: objectui#8155 removed it from the predicate scope, because // neither ADR-0068 nor the engine's `SCOPE_ROOTS` declares such a root. + // ⛔ No `data`: objectui#8166 removed that one too. It was `{}` here, so + // a `data.*` field predicate faulted with `No such key` and fell back + // fail-open; it now faults with the engine's own `Unknown variable: data`, + // the verdict the server gives the same string. createExpressionEvaluator({ // expressionUser already handles the anonymous fallback, so we can // pass it through unconditionally. user: expressionUser, - data: {}, features, }), [expressionUser, features], diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts index 57b5bd6291..b820974115 100644 --- a/packages/react/src/utils/visibilityDiagnostic.ts +++ b/packages/react/src/utils/visibilityDiagnostic.ts @@ -117,7 +117,7 @@ function predicateSourceText(raw: unknown): string { * does not promise and this paragraph therefore does not advertise). * * `'app-shell'` — the chrome gate. `ExpressionProvider.tsx` builds - * `{ current_user, user, ctx: { user }, os: { user }, data, features }`. + * `{ current_user, user, ctx: { user }, os: { user }, features }`. * There is no `record` and no `page` in it at all, which is the defect this * type exists to fix. The four identity spellings are the ADR-0068 aliases and * all four resolve; `features` is the deployment-flag root that provider's own @@ -131,10 +131,14 @@ function predicateSourceText(raw: unknown): string { * predicate faults, so it would answer "why did my predicate not resolve?" * with the root that is the reason. * - * `data` is bound at the app-shell tier but is deliberately NOT advertised - * there: every mount of `ExpressionProvider` in this repo passes `data={{}}` - * or omits it, so naming it would point an author at a root that answers - * nothing. + * ⛔ No `data` either, since objectui#8166 (ruled 2026-09-10). It WAS bound at + * the app-shell tier and was already withheld from the paragraph below, on the + * grounds that every `ExpressionProvider` mount passes `data={{}}` — but + * `AppContent`'s own field-list evaluator passed the record under edit, so on + * that one leg an authored `data.*` resolved silently against the host's record + * instead of faulting. `buildExpressionScope` stopped binding it; a `data.*` + * predicate at this tier now reaches this reporter with the engine's + * `Unknown variable: data`, the same verdict the server gives it. */ export type PredicateScopeTier = 'page-component' | 'app-shell'; From 0d40228774f6f62da13634efa6ff0fbcf42c5e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:49:39 +0000 Subject: [PATCH 2/4] test(app-shell): re-judge the row-predicate fixtures the unbinding flips (#8166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConditionalFormattingEditor.test.tsx` built its host bag by calling `buildExpressionScope({ user, data: {}, features })` and pinned that an ambient `data` RESOLVES while the row is unreachable through it — closing with a comment naming objectui#8166 as "the half of the retirement this card does not close". That is the branch this card deletes, so the fixture is re-judged rather than re-spelled: the `data:` argument is now a compile error (the same third fence objectui#8155 left on `app`), `CURATED_EXCLUSIONS` drops to empty — the host no longer binds a root this editor withholds — and the probe that read TRUE now reads FALSE against the producer's own bag. The list machinery is kept, empty, because its derivation is the fence that catches a NEW unadvertised host root. The editor's own docblock said an ambient `data` means `data.*` "still RESOLVES"; that is now false for this host and is corrected, with the producer half (`SCOPE_ROOTS` still accepts `data` at `scope: 'record'`) stated as the part that did not move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../ConditionalFormattingEditor.test.tsx | 58 ++++++++++++++----- .../ConditionalFormattingEditor.tsx | 25 +++++--- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx index 3ea38151f5..a16e83f6ba 100644 --- a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx @@ -285,26 +285,42 @@ describe('ROW_PREDICATE_ROOTS ↔ evalRowPredicate runtime contract', () => { * returned bag, so passing one is a COMPILE error (TS2353, "'app' does not * exist in type 'ExpressionScopeInput'"). That is a third fence on the same * fact, and the only one that holds without the suite being run. + * + * ⭐ And no `data`, on the same terms, since objectui#8166 — this block + * passed `data: {}` until that card, and the argument is now the same + * compile error. See {@link CURATED_EXCLUSIONS} for what that closed. */ const fullHostScope = buildExpressionScope({ user: u, - data: {}, features: { beta: true }, }); /** - * The ONE root the host binds that this editor deliberately does not - * advertise: `data`, retired on row surfaces (objectui#5741). It gets its own - * pin below, against `fullHostScope`, which does carry it. + * Roots the host binds that this editor deliberately does not advertise — + * **empty since objectui#8166**, and that emptiness is the finding rather + * than a tidy-up. + * + * It held exactly one member, `data`: retired on row surfaces by + * objectui#5741 and therefore unadvertised here, yet still BOUND by + * `buildExpressionScope`, so an authored `data.*` resolved against the host's + * object instead of the row. objectui#8166 unbound it, so there is no longer + * a root that this editor withholds while the host still answers it — the + * advertised list and the host bag now describe the same set, which is what + * the closing assertion of the next test measures. * * `os` used to sit here too. objectui#8155 ruled it back onto the advertised * list in the same patch that removed `app`: it is bound here, ACCEPTED by * the engine, and the measured in-tree identity spelling * (`record.owner == os.user.id`), so withholding it was curation with * nothing behind it. + * + * ⛔ Kept as a (now empty) list rather than deleted, deliberately: the + * derivation below is the fence that catches a NEW host root arriving + * unadvertised, and collapsing it to `hostScope = fullHostScope` would + * quietly retire that fence along with its last entry. */ - const CURATED_EXCLUSIONS = ['data']; + const CURATED_EXCLUSIONS: string[] = []; /** - * The same bag with those two removed. Probes for the ADVERTISED roots run + * The same bag with those removed. Probes for the ADVERTISED roots run * against this one, so no probe can pass off a host binding as a row binding. */ const hostScope = Object.fromEntries( @@ -358,20 +374,30 @@ describe('ROW_PREDICATE_ROOTS ↔ evalRowPredicate runtime contract', () => { expect(evalRowPredicate("status == 'overdue'", row, { fallback: false, scope: hostScope })).toBe(false); }); - it('`data` is RETIRED: unadvertised, and an ambient host `data` never names the row', () => { + it('`data` is RETIRED: unadvertised HERE and no longer bound by the host either', () => { expect(ROW_PREDICATE_ROOTS).not.toContain('data'); - // A host may still legitimately carry its own ambient `data` — app-shell's - // `buildExpressionScope` does, and this is that bag rather than a model of - // it. That is what made the old probe useless... + // ⭐ objectui#8166 flipped this reading, and the flip is the point. + // + // Until that card the host DID carry an ambient `data`, so this probe + // answered TRUE: the root resolved, against the host's own object, while + // the row was not reachable through it at all. An author got a green lint, + // a resolving predicate, and an answer from the wrong layer. + // + // This is `buildExpressionScope`'s own output rather than a model of it, so + // the assertion tracks the producer: `data` restored to that bag reddens + // here. const ambient = fullHostScope; - expect(evalRowPredicate('size(data) >= 0', row, { fallback: false, scope: ambient })).toBe(true); - // ...while the ROW is not reachable through it at all. Canonical spelling - // against the same scope, so the two differ only in the spelling. + expect(evalRowPredicate('size(data) >= 0', row, { fallback: false, scope: ambient })).toBe(false); + // The canonical spelling against the SAME scope, so the two differ only in + // the spelling — and it is the one that reaches the row. expect(evalRowPredicate("record.status == 'overdue'", row, { fallback: false, scope: ambient })).toBe(true); expect(evalRowPredicate("data.status == 'overdue'", row, { fallback: false, scope: ambient })).toBe(false); - // ⚠️ The line above is FALSE at runtime while the authoring pin above - // ("a `data.*` condition still lints CLEAN") is green. That pair is the - // half of the retirement this card does not close — objectui#8166. + // ⚠️ What objectui#8166 did NOT close: the authoring pin above (a `data.*` + // condition still lints CLEAN at `scope: 'record'`) is still green, because + // the accept set is `@objectstack/formula`'s `SCOPE_ROOTS` and splitting it + // per scope is the producer-side half, in another repo. The consumer half + // is what changed: the predicate no longer RESOLVES, it faults with the + // engine's own `Unknown variable: data`. }); it('the engine-default extras stay unadvertised because they are NOT bound', () => { diff --git a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx index 0f1360fc9f..3df1a1c49c 100644 --- a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx @@ -48,12 +48,14 @@ import type { CelLintIssue } from './celAuthoring.js'; * this row any more; both fault as unknown variables, exactly as they always * did on the server. * - * `data` is therefore off this list. The subtlety worth keeping: a host scope - * may legitimately carry its OWN ambient `data` (app-shell's - * `buildExpressionScope` does), so `data.*` still RESOLVES — against the - * host's object rather than the row. That is the constant-false signature - * `rowPredicateCanon.ts` describes, and it is why "does `data` resolve?" is - * not a test of whether `data` names the row. + * `data` is therefore off this list. The subtlety it used to carry — that a + * host scope may legitimately bind its OWN ambient `data`, so `data.*` still + * RESOLVED against the host's object rather than the row — no longer applies to + * THIS host: objectui#8166 unbound it in `buildExpressionScope`. The subtlety + * is still worth stating for a host that does bind one (a rowless dialog, the + * metadata-admin form): "does `data` resolve?" is not a test of whether `data` + * names the row, which is why `ConditionalFormattingEditor.test.tsx` probes it + * against the producer's real bag instead of a hand-written literal. * * The engine's default advertisement adds `previous` / `input` / `vars`, none * of which are bound for row predicates at all. Suggesting an unbound root @@ -83,9 +85,14 @@ import type { CelLintIssue } from './celAuthoring.js'; * `core/src/evaluator/__tests__/listConditional.test.ts`. Withholding a root * that is bound, accepted AND used was curation with nothing behind it. * - * `data` is deliberately still absent, and that is NOT the same case: the - * engine accepts it but the row is not reachable through it. That half is - * objectui#8166. + * `data` is deliberately still absent, and that is NOT the same case as either: + * the engine ACCEPTS it — `SCOPE_ROOTS` still carries it, so a `data.*` + * condition lints clean at `scope: 'record'` to this day — but the row is not + * reachable through it. objectui#8166 settled the half this repo owns: the host + * no longer binds an ambient `data`, so such a condition now FAULTS with the + * engine's own `Unknown variable: data` instead of resolving against the host's + * object. Narrowing the accept set itself is the producer-side half and lives + * in `@objectstack/formula`, not here. */ export const ROW_PREDICATE_ROOTS = [ 'record', From 1fa576c7edc25a2713a18506ee9a2fdd1e7f1c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:58:31 +0000 Subject: [PATCH 3/4] test(components): drop the retired `data` root from the transcribed host scope (#8166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three form/predicate tests hand-transcribe the bag `ExpressionProvider` mounts, because importing `@object-ui/app-shell` from `@object-ui/components` would invert the dependency. Each literal carried `data: {}`, which objectui#8166 removed from the producer. None of them would have gone RED: their predicates name `current_user`, `os` and a deliberately unbound root, so an extra root changes no verdict. That is the hazard rather than a reason to leave it — a transcription cannot disagree with its producer, so it absorbs the producer's drift in silence while claiming, in its own docblock, to be "the host scope `ExpressionProvider` mounts". `predicate-scope-parity-6010.test.tsx` also cited `ExpressionProvider.tsx:59,70`; that pair had already drifted, so the reference is re-keyed BY SYMBOL (`buildExpressionScope`) rather than re-counted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../fieldtab-visiblewhen-6237.test.tsx | 10 ++++++++-- .../predicate-scope-parity-6010.test.tsx | 17 ++++++++++++----- .../__tests__/section-grouping-6236.test.tsx | 10 ++++++++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx index cb334fa0c1..12ec3fb18e 100644 --- a/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx +++ b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx @@ -74,10 +74,16 @@ const GATE = cel("'sales_manager' in current_user.positions"); /** A root NOTHING binds — "faulted" must be unambiguous. */ const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions"); -/** The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). */ +/** + * The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). + * + * ⛔ No `data` since objectui#8166, and no `app` since objectui#8155 — this + * literal claims to be that bag, so a root it carries that the producer does + * not bind is a false claim that no assertion here would redden. + */ function hostScope(positions: string[]) { const user = { id: 'u1', name: 'Kim', positions }; - return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} }; + return { current_user: user, user, ctx: { user }, os: { user }, features: {} }; } const DENIED = hostScope(['sales']); diff --git a/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx b/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx index f0ec2b13fe..5e532d5731 100644 --- a/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx +++ b/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx @@ -103,17 +103,24 @@ const GATE = cel("'sales_manager' in current_user.positions"); const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions"); /** - * The scope `packages/app-shell/src/providers/ExpressionProvider.tsx:59,70` - * really mounts, transcribed rather than imported: `@object-ui/app-shell` - * depends on `@object-ui/components`, so importing it from here would invert - * the dependency. The aliases are not decoration — `SchemaRenderer` re-derives + * The scope `buildExpressionScope` (`packages/app-shell/src/providers/ + * ExpressionProvider.tsx`) really mounts, transcribed rather than imported: + * `@object-ui/app-shell` depends on `@object-ui/components`, so importing it + * from here would invert the dependency. Anchored by SYMBOL rather than by the + * `:59,70` line pair it used to name, which had already drifted. + * + * ⛔ No `data` (objectui#8166) and no `app` (objectui#8155). A transcription + * cannot disagree with its producer, so it absorbs the producer's drift + * silently — a root left here that the real bag no longer binds would keep + * every assertion below green while describing a scope that does not exist. + * The aliases are not decoration — `SchemaRenderer` re-derives * `current_user` from `scope.user`, so a scope carrying only `current_user` * would gate correctly on the two form surfaces and NOT on the page one, which * is the very asymmetry this file exists to refuse. */ function hostScope(positions: string[]) { const user = { id: 'u1', name: 'Kim', positions }; - return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} }; + return { current_user: user, user, ctx: { user }, os: { user }, features: {} }; } const DENIED = hostScope(['sales']); diff --git a/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx b/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx index 5c4c2e304a..70dffd7848 100644 --- a/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx +++ b/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx @@ -66,10 +66,16 @@ const GATE = cel("'sales_manager' in current_user.positions"); /** A root NOTHING binds — "faulted" must be unambiguous. */ const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions"); -/** The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). */ +/** + * The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). + * + * ⛔ No `data` since objectui#8166, and no `app` since objectui#8155 — this + * literal claims to be that bag, so a root it carries that the producer does + * not bind is a false claim that no assertion here would redden. + */ function hostScope(positions: string[]) { const user = { id: 'u1', name: 'Kim', positions }; - return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} }; + return { current_user: user, user, ctx: { user }, os: { user }, features: {} }; } const DENIED = hostScope(['sales']); From cee30c80c57486b1f4069cf991fe4f59737d32e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 12:02:59 +0000 Subject: [PATCH 4/4] chore(changeset): declare @object-ui/components for the transcription fix (#8166) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .changeset/8166-record-scope-data-root.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.changeset/8166-record-scope-data-root.md b/.changeset/8166-record-scope-data-root.md index 5a4723d020..d42c721453 100644 --- a/.changeset/8166-record-scope-data-root.md +++ b/.changeset/8166-record-scope-data-root.md @@ -1,6 +1,7 @@ --- '@object-ui/app-shell': minor '@object-ui/react': patch +'@object-ui/components': patch --- Stop binding an ambient `data` root on record surfaces, so a `data.*` predicate faults @@ -50,4 +51,8 @@ metadata-admin designer is unaffected: its `data` is the DRAFT under edit (ADR-0 identity roots from this bag and assigns its own `data` last. `@object-ui/react` carries a docblock correction only: the `app-shell` tier paragraph in -`utils/visibilityDiagnostic.ts` described the bag this change edits. +`utils/visibilityDiagnostic.ts` described the bag this change edits. `@object-ui/components` +carries no runtime change at all — three form/predicate tests hand-transcribe the app-shell +bag (importing it would invert the package dependency) and each literal carried the removed +`data: {}`; the transcriptions are corrected so they cannot go on describing a scope that no +longer exists.