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
12 changes: 7 additions & 5 deletions .changeset/6487-visibility-advice-per-tier.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ prose that described it:
byte-for-byte what it was.
- **`'app-shell'`** — the chrome gate `ExpressionProvider.evaluateVisibility`
runs, wired onto this reporter by objectui#6443. Its evaluator is built from
`{ current_user, user, ctx: { user }, os: { user }, app, data, features }`, so
the line now names `current_user` with its three ADR-0068 alias spellings,
`app`, and `features` — the deployment-flag root that provider documents for
exactly this kind of predicate — and states outright that `record` and
`page.<var>` do not exist there.
`{ current_user, user, ctx: { user }, os: { user }, data, features }`, so
the line now names `current_user` with its three ADR-0068 alias spellings and
`features` — the deployment-flag root that provider documents for exactly
this kind of predicate — and states outright that `record` and `page.<var>`
do not exist there. (That bag and that paragraph also carried an `app` root
when this entry was first written; objectui#8155 removed it from both before
either shipped, so the released message names five roots, not six.)

**Why not generalise the copy instead.** Dropping the concrete root names would
have made one paragraph true everywhere at the cost of making it useful nowhere:
Expand Down
35 changes: 28 additions & 7 deletions .changeset/7727-conditional-formatting-record-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,34 @@ consumer aligns to it, rather than the engine growing a root to match this consu
population cannot be measured from this repository.** In-tree usage is zero — swept
across `packages/`, `apps/`, `examples/` and `content/` with a firing control — but
metadata authored in real deployments lives outside this tree and no sweep here can
see it. Any predicate that reads `app.*` — a conditional-formatting condition, an
action `visible` / `disabled`, a field `visibleWhen` — stops resolving and, because
unresolvable visibility predicates **fail open**, will start reading as "yes" rather
than erroring. That is the accepted cost of the ruling, not an oversight. There is no
replacement root: `app` was never in the protocol. If you need a "current app" value in
a predicate, that is a spec/engine vocabulary widening to be filed (the producer-side
card, objectstack#16420, stays open as the record to reopen).
see it. Any predicate that reads `app.*` stops resolving. There is no replacement
root: `app` was never in the protocol. If you need a "current app" value in a
predicate, that is a spec/engine vocabulary widening to be filed fresh — the
producer-side card objectstack#16420 was closed `not_planned` by the same ruling
(2026-09-07), so there is no open record waiting for it.

**What a stale `app.*` predicate does now depends on the surface — the direction is
NOT uniform, and two of them fail the safe way.** Measured per surface on the merged
head, each with a resolvable control predicate firing in the same run:

| Surface | Entry point | A stale `app.*` predicate now |
|---|---|---|
| Conditional-formatting `condition` | `resolveConditionalFormatting` → `evalRowPredicate` (`fallback: false`) | **fails CLOSED** — the rule silently stops matching, no style is applied |
| Row/header action `visible` / `disabled` | `evalRowPredicate` (`fallback: false`) | **fails CLOSED** — the action is hidden / left enabled |
| Action `visible` on `action-button` / `action-menu` / `action-bar` | `useCondition(…, { throwOnError: true })` | **fails CLOSED** — hidden, with a one-time console warning |
| Action `visible` on `action-icon` / `action-group` | `useCondition` (default) | **fails OPEN** — the action is shown |
| Field `visibleWhen` (form field rules) | `resolveFieldRuleState` → `evalFieldPredicate` (fallback `true`) | **fails OPEN** — the field is shown |
| Field `visibleWhen` (app-shell object field) and nav / area `visible` | `isObjectFieldVisible` / `evaluateVisibility` | **fails OPEN** — shown, with a console diagnostic |
| Field `readonlyWhen` / `requiredWhen` | `resolveFieldRuleState` (fallback `false`) | **fails CLOSED** — not readonly, not required |

So the cost is not one shape: on the fail-OPEN surfaces a gate that used to hide
something starts showing it, and on the fail-CLOSED surfaces a rule that used to fire
silently stops. Both are accepted costs of the ruling, not oversights — but they need
opposite checks after upgrading, which is why they are listed apart rather than
summarised. Every faulting predicate warns on the console; the app-shell diagnostic
names the roots this tier really binds, and objectui#8155's follow-up removed `app`
from that list so it no longer sends an author back to the root that is the reason
(`packages/react/src/utils/visibilityDiagnostic.ts`).

`ExpressionProvider` still accepts an `app` prop and still publishes `app` on its React
**context value**, which components read as a plain value (`DashboardView` does). Only
Expand Down
36 changes: 36 additions & 0 deletions .changeset/8155-app-root-residue-swept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/react': patch
---

The published visibility diagnostic no longer tells an author that `app` is a bound
expression-scope root (objectui#8155 follow-up).

`@object-ui/react`'s `SCOPE_TIER_ADVICE['app-shell']` — the paragraph
`reportUnresolvableVisibilityPredicate` prints in production when a predicate cannot be
evaluated — read "App-shell predicates bind `current_user` … plus `app` and `features`".
objectui#8155 removed `app` from `buildExpressionScope`, so that sentence was printed at
exactly the moment a saved `app.*` predicate faulted, and it answered "why did my
predicate not resolve?" by naming the root that is the reason. The line now names the
five roots the provider really binds: `current_user`, its `user` / `ctx.user` / `os.user`
aliases, and `features`.

**Swept as a class, not as two coordinates.** Every other place in this tree that stated
`app` was a bound expression-scope root is corrected in the same change — the diagnostic
copy and its byte-pin, the ambient-scope docblocks in `@object-ui/react`
(`SchemaRenderer`, `useExpression`), `@object-ui/core` (`ActionRunner.ParamDef.visible`,
`RowPredicateOptions.scope`), `@object-ui/components` (`form.tsx`, `containers.tsx`),
`@object-ui/plugin-detail`, `@object-ui/plugin-form` (docblock and README),
`@object-ui/app-shell` and the console app, plus fourteen test fixtures that transcribed
the old bag with an `app` key. The fixtures in `@object-ui/app-shell` now call
`buildExpressionScope` instead of transcribing it, so that pair cannot drift again.

Nothing that was true before objectui#8155 changed: the `app` prop on
`ExpressionProvider` and the `app` field on its React context value are untouched (they
were never CEL roots), and no root other than `app` was added to or removed from any
message, bag or fixture.

Two release-bound corrections travel with it. The producer-side card objectstack#16420
was closed `not_planned` on 2026-09-07 by the same ruling that removed the root, not left
open; and the consequence of a stale `app.*` predicate is **not** uniformly "fails open" —
it is per surface, and the changeset that carries the removal now states the seven
measured directions instead of one.
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
*/
function hostScope(positions: string[]) {
const user = { id: 'u1', name: 'Kim', role: 'user', isPlatformAdmin: false, positions };
return { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} };
}

/** The SAME principal shape, one admitted by `GATE` and one refused by it. */
Expand Down
3 changes: 2 additions & 1 deletion apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1677,7 +1677,8 @@ export function FormPage({ mode, recordPath }: FormPageProps) {
const isCreateForm = target.kind !== 'edit';
/**
* The host shell's global predicate scope — `current_user` plus the ADR-0068
* `user` / `ctx.user` / `os.user` aliases, `app`, `data`, `features` — read
* `user` / `ctx.user` / `os.user` aliases, `data`, `features` (⛔ no `app`:
* objectui#8155 unbound that root) — read
* from the `PredicateScopeProvider` an `ExpressionProvider` mounts, and
* threaded into all three evaluators below (objectui#6110). Same binding the
* sibling renderer took in #6010, so one authored `visibleWhen` means one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { describe, it, expect } from 'vitest';
import { ExpressionEvaluator } from '@object-ui/core';
import { evaluateVisibility } from './ExpressionProvider';
import { buildExpressionScope, evaluateVisibility } from './ExpressionProvider';

/**
* Regression: nav/area `visible` predicates arrive from the server as
Expand All @@ -20,9 +20,14 @@ import { evaluateVisibility } from './ExpressionProvider';
* unimplementable from app metadata.
*/

/**
* The provider's own bag, from the provider's own builder. It used to be a
* hand-written transcription carrying an `app` key; `buildExpressionScope` has
* not bound one since objectui#8155 (ruled 2026-09-07), and a copy is how a
* fixture keeps asserting a root the shipped builder no longer has.
*/
function makeEvaluator(user: Record<string, unknown>) {
const context = { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
return new ExpressionEvaluator(context as any);
return new ExpressionEvaluator(buildExpressionScope({ user }) as any);
}

describe('evaluateVisibility', () => {
Expand Down
16 changes: 10 additions & 6 deletions packages/app-shell/src/providers/ExpressionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@ export interface ExpressionScopeInput {
*
* The ruling is that the engine's `SCOPE_ROOTS` is the contract and this
* consumer aligns to it, NOT that the engine grows a root to match this
* consumer (option A, objectstack#16420, is explicitly not taken and stays
* open as the record to reopen should a real need for a "current app" root
* ever be measured). ⛔ The other refused route was suppressing the diagnostic
* in `celAuthoring.ts`: that is the lenient-fallback shape AGENTS.md #0.1
* bans.
* consumer. Option A — widen the engine vocabulary — was the producer-side
* card objectstack#16420, and the same ruling CLOSED it `not_planned`
* (2026-09-07T04:16:22Z). Should a real need for a "current app" root ever be
* measured, it is a fresh spec/engine vocabulary widening; there is no open
* record waiting for it. ⛔ The other refused route was suppressing the
* 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.
Expand Down Expand Up @@ -310,7 +312,9 @@ export function evaluateVisibility(
// NODE tier's advice, telling an author whose nav predicate faulted to
// check `record` and `page.<var>` — two roots the bag built in
// `ExpressionProvider` above does not contain at all — while the identity
// aliases, `app` and `features` that it DOES contain went unnamed.
// aliases and `features` that it DOES contain went unnamed. (`app` was in
// that list until objectui#8155 removed the root; the advice no longer
// names it either.)
'app-shell',
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,24 @@ import {
__resetVisibilityPredicateWarnings,
} from '@object-ui/react';
import { hasVisibleNavigationItems } from '@object-ui/layout';
import { evaluateVisibility } from './ExpressionProvider';
import { buildExpressionScope, evaluateVisibility } from './ExpressionProvider';

/** The label this site puts in the reporter's `type` slot — the dedupe-key decision. */
const SURFACE = 'app-shell:visible';

/**
* The provider's bag, taken from the provider's own builder rather than
* transcribed. It was a hand-written copy carrying an `app` key, and a copy is
* how a fixture goes on asserting a root the builder has stopped binding
* (objectui#8155) — `buildExpressionScope` is the single declaration, so the
* cells below measure the shipped bag instead of a snapshot of it.
*/
function makeScope(user: Record<string, unknown> = { id: 'u1', positions: ['worker'] }) {
return buildExpressionScope({ user });
}

function makeEvaluator(user: Record<string, unknown> = { id: 'u1', positions: ['worker'] }) {
const context = { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
return new ExpressionEvaluator(context as any);
return new ExpressionEvaluator(makeScope(user) as any);
}

type WarnSpy = { mock: { calls: unknown[][] } };
Expand Down Expand Up @@ -321,8 +331,8 @@ describe('objectui#6443 — the rate limit, measured in both directions', () =>
* The card #6443 made visible: once this site started printing, it printed the
* NODE gate's closing paragraph, telling a nav author to check `record` and
* `page.<var>`. The bag `ExpressionProvider` builds is
* `{ current_user, user, ctx: { user }, os: { user }, app, data, features }` —
* it contains neither.
* `{ current_user, user, ctx: { user }, os: { user }, data, features }` — it
* contains neither.
*
* These cells are the END-TO-END half of the pin: the unit matrix in
* `@object-ui/react` proves the two paragraphs differ, and these prove the
Expand All @@ -347,19 +357,73 @@ describe('objectui#6487 — the line carries the APP-SHELL tier`s roots', () =>
it('it names the roots this provider really binds — including `features`', () => {
// `features` is the deployment-flag root this provider's own docblock
// documents for exactly this kind of predicate, and it was unnamed.
// Asserted against the bag `makeEvaluator` builds, which is a copy of the
// provider's: every root named below is a key of it.
// Asserted against the bag `makeEvaluator` builds, which IS the provider's
// (`buildExpressionScope`): every root named below is a key of it.
const warn = spyWarn();
const evaluator = makeEvaluator();

evaluateVisibility('nosuchroot6487shellroots.x > 1', evaluator);
const [line] = reports(warn);
expect(line).toBeDefined();
for (const root of ['`current_user`', '`user`', '`ctx.user`', '`os.user`', '`app`', '`features`']) {
for (const root of ['`current_user`', '`user`', '`ctx.user`', '`os.user`', '`features`']) {
expect(line).toContain(root);
}
});

/* ------------------------------------------------------------------------ *
* objectui#8155 — the COUPLING pin. The advice copy lives in
* `@object-ui/react` and the bag lives here, so neither package can pin the
* pair alone; this is the only seat that reaches both. Modelled on the
* three-sided pin PR #8164 left on `ConditionalFormattingEditor.test.tsx`.
* ------------------------------------------------------------------------ */

it('objectui#8155 — the printed advice does not name `app`, and the bag does not bind it', () => {
// Face 1 (copy, `@object-ui/react`) and face 2 (bag, this package) asserted
// in one run against the REAL line this surface prints. Re-adding `app` to
// the diagnostic string reddens the first expect; re-adding it to
// `buildExpressionScope` reddens the second.
const warn = spyWarn();

evaluateVisibility('nosuchroot8155copy.x > 1', makeEvaluator());
const [line] = reports(warn);
expect(line).toBeDefined();
expect(line).not.toContain('`app`');
expect(Object.prototype.hasOwnProperty.call(makeScope(), 'app')).toBe(false);
});

it('objectui#8155 — every root the advice names is a root the bag really binds', () => {
// Face 3, and the one that makes the pair a fence rather than two lists.
// The defect this card cleans up was precisely an advertised root the bag
// did not bind, so the invariant — not the spelling — is what is pinned:
// re-adding `app` to the COPY alone (leaving the bag aligned to the
// engine) reddens here even though the census cell above is the one that
// names it. The reverse case, a root bound but not advertised, is legal
// and deliberate (`data`), so this direction is asserted and not the other.
const warn = spyWarn();
const scope = makeScope();

evaluateVisibility('nosuchroot8155coupling.x > 1', makeEvaluator());
const [line] = reports(warn);
expect(line).toBeDefined();

// Root names as the advice spells them, mapped to the key an author would
// have to be able to name for the advice to be true. `ctx.user` / `os.user`
// are member paths, so the root is the segment before the dot.
const advertised = ['current_user', 'user', 'ctx.user', 'os.user', 'app', 'features', 'record', 'data']
.filter((root) => (line as string).includes(`\`${root}\``))
.map((root) => root.split('.')[0]);
expect(advertised.length).toBeGreaterThan(0); // the line named SOMETHING
for (const root of advertised) {
// `record` is named only by the sentence declaring it ABSENT at this
// tier, which is the one advertised name that must NOT be a key.
if (root === 'record') {
expect(Object.prototype.hasOwnProperty.call(scope, root)).toBe(false);
continue;
}
expect(Object.prototype.hasOwnProperty.call(scope, root)).toBe(true);
}
});

it('CONTROL: the first paragraph is UNCHANGED — it is true on this fail-open surface too', () => {
// Green both ways, deliberately. Only the LAST paragraph is per-tier; the
// "gate did NOT bite" sentence is true on every surface wired to this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,11 @@ export interface ConditionSubjectVocabulary {
* bound through `evalFieldPredicate`'s `scope` extra.
*
* NOT included, on purpose: `data`, `os`, `app`, `features`, `input`, `vars`,
* `page`. Those are real roots at some surfaces, but this builder never offers
* them, and over-capturing there fails in the WRONG direction — `data.csv` is
* `page`. All but `app` are real roots at some surface — `app` is a root at
* none since objectui#8155 unbound it, and it stays on this list because the
* list is what this builder does not capture, not what exists. This builder
* never offers any of them, and over-capturing there fails in the WRONG
* direction — `data.csv` is
* a plausible literal, and `data` IS bound, so reading it as a reference would
* produce another silently-false predicate instead of a loud one. Which roots
* a mounting surface actually binds is caller-supplied vocabulary
Expand Down
7 changes: 4 additions & 3 deletions packages/app-shell/src/views/metadata-admin/predicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,10 @@ export interface PredicateCtx {
* RECORD; metadata-admin edits source metadata and has no record. Leaving it
* unbound is what keeps `record.status` a loud diagnostic here instead of a
* silent false — `predicate.test.ts` pins that.
* - ⛔ `app` / `features`. Renderer-tier, not ADR-0068 identity, and not named
* by the ruling. Binding them as empty objects would turn `app.x` from a loud
* unresolved-root warning into a silent `undefined`.
* - ⛔ `app` / `features`. `features` is renderer-tier, not ADR-0068 identity,
* and not named by the ruling; `app` is not a root at ANY tier since
* objectui#8155 unbound it. Binding either as an empty object would turn
* `app.x` from a loud unresolved-root warning into a silent `undefined`.
*/
export const IDENTITY_ROOTS = ['current_user', 'user', 'ctx', 'os'] as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions");
/** The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). */
function hostScope(positions: string[]) {
const user = { id: 'u1', name: 'Kim', positions };
return { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} };
}

const DENIED = hostScope(['sales']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions");
*/
function hostScope(positions: string[]) {
const user = { id: 'u1', name: 'Kim', positions };
return { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} };
return { current_user: user, user, ctx: { user }, os: { user }, data: {}, features: {} };
}

const DENIED = hostScope(['sales']);
Expand Down
Loading
Loading