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
115 changes: 115 additions & 0 deletions .changeset/7727-conditional-formatting-record-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
'@object-ui/app-shell': minor
---

Lint conditional-formatting conditions in the `record` scope, stop advertising `data`
(objectui#7727), and align the predicate scope's root vocabulary to the engine's —
`app` is removed, `os` is advertised (objectui#8155).

**Breaking for authors, deliberately.** A bare field reference in a list/grid/kanban
`conditionalFormatting` condition — `status == 'overdue'` — used to lint clean in
Studio's conditional-formatting editor and now raises a blocking error carrying the
`record.status` fix.

**Read this before upgrading.** The error is a *blocking* one: it bubbles through
`onBlockingIssuesChange` (objectui#4527), which the inspector aggregates and the host
that owns Save reads. So an already-saved view whose `conditionalFormatting` carries a
legacy bare condition becomes **unsavable in the designer until that condition is
rewritten** — including when you opened the view to change something unrelated. Nothing
is migrated automatically and nothing at runtime changes: those conditions were already
dead (see below), the editor just stops hiding it. Rewrite `status == 'overdue'` as
`record.status == 'overdue'`.

The editor was teaching a spelling the runtime had already retired. objectui#5741
(Phase 2 of the objectui#5330 canon, ruled 2026-09-02 and amended 2026-09-05) unbound
the bare shorthand and `data.*` on runtime record surfaces: `evalRowPredicate` binds the
row as `record.*` and nothing else, so `status == 'overdue'` faults with
`Unknown variable: status` and the authored rule never matches. The editor nevertheless
linted it green, because it authored in the `flattened` scope — where any bare
identifier is legal. That is declared-but-unenforced in the direction that costs an
author a silently dead formatting rule.

On `ConditionalFormattingEditor`:

- its `CelPredicateField` authors in `scope="record"`, the scope the field conditional
rules `visibleWhen` / `readonlyWhen` / `requiredWhen` already use;
- `ROW_PREDICATE_ROOTS` loses `'data'`, which Phase 2 retired but autocomplete was
still recommending. It is an `export const`, but **not** on this package's
published face: `@object-ui/app-shell`'s `index.ts` has no `export *` lines and
re-exports neither the const nor this editor, and the package `exports` map is
`"."` plus `./styles.css` with no deep subpath — so no consumer outside the
package can import it, and nothing you depend on changes shape;
- the docblock and inline comment that described the old three-way binding are
rewritten to the one binding that survives.

## The `app` root is removed from the predicate scope (objectui#8155)

Ruled 2026-09-07. `app` was the mirror of the bug above, one level up: app-shell
*bound* it, this editor *advertised* it, and the engine that lints the very same field
*refused* it — ADR-0068 declares `current_user` with the `user` / `ctx.user` aliases
and nothing named `app`, and `@objectstack/formula`'s `SCOPE_ROOTS` has no `app`
either. So `app.name == 'crm'` raised a blocking error whose suggested remedy,
`record.app`, was nonsense, and there was **no** spelling that both linted clean and
resolved. The ruling is that the engine's `SCOPE_ROOTS` is the contract and this
consumer aligns to it, rather than the engine growing a root to match this consumer.

`buildExpressionScope` (`providers/ExpressionProvider.tsx`) therefore no longer binds
`app`, and `ROW_PREDICATE_ROOTS` no longer advertises it.

⚠️ **This is breaking for anyone whose saved metadata spells `app.*`, and that
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).

`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
the **expression scope** loses it — those are two different things, and only the second
was ever a CEL root.

## `os` is now advertised (same ruling, opposite direction)

`os` was the exact mirror: **bound** by `buildExpressionScope`, **accepted** by the
engine, and merely never offered — the one root an author could legitimately write but
would never be shown. It is also the spec's canonical identity spelling
(`os.user.id`) and the measured in-tree one: authored predicates spell
`record.owner == os.user.id` across `packages/core`, `packages/components` and
`packages/plugin-grid`, including a conditional-formatting `condition`. It joins
`ROW_PREDICATE_ROOTS`. This is additive — nothing that linted clean before stops doing
so.

**Autocomplete moves with the scope.** Under `scope="record"`, `CelPredicateField`
builds its bare-position catalog with `fields: []`, so typing `sta` at the start of a
condition no longer offers `status`; fields are offered as member completion after
`record.` instead. That is the correct affordance for the new scope — the bare form it
used to complete is now an error — and the member-completion list itself is unchanged:
the engine's `introspectScope` returns byte-identical `fields` for `record` and
`flattened` (measured against `@objectstack/formula@17.2.0`; it echoes the caller's
`fields` hint rather than deriving one per scope).

The `flattened` default at the shared authoring seam is **untouched**: RLS predicates
and flow conditions are not row surfaces (objectui#5738 stand-down 3) and stay
flattened.

**What this does NOT close — one half is left open, and it is filed.**

- **The `data.*` half.** Dropping `'data'` from `ROW_PREDICATE_ROOTS` stops
*recommending* it; it does not stop the lint *accepting* it.
`@objectstack/formula`'s `SCOPE_ROOTS` lists `data`, so `data.status == 'x'` still
lints clean at `scope:'record'` while resolving against the host's ambient `data`
rather than the row — constant-false, silently. Pinned here as a characterization
test, tracked as objectui#8166. This changeset closes the **bare-field** half of the
retirement only.

⛔ And this editor is **not** the last authoring site still on the flattened default —
`ConditionBuilder` reaches it by passing no `scope` at all, which is why a grep for the
explicit spelling missed it. An action's `visible` / `disabled` guard is a row predicate
by the canon's own words and still lints bare refs clean. Filed as objectui#8167; ⛔ not
fixed here, because three of `ConditionBuilder`'s six callers need a per-surface tier
verdict first.
6 changes: 4 additions & 2 deletions content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ gets its real widget, not a text box (ADR-0059):
inherit label, type, options, lookup picker config, `multiple`, `accept`,
and `maxSize` from the object's field definition; inline properties override.
- `required` blocks submit while the value is empty; `visible` (a CEL
predicate over `features` / `current_user` / `app` / `data`) hides a param
entirely — e.g. gate a param on an opt-in server capability.
predicate over `features` / `current_user` / `data`) hides a param
entirely — e.g. gate a param on an opt-in server capability. There is no
`app` root: objectui#8155 removed it, because neither ADR-0068 nor
`@objectstack/formula`'s `SCOPE_ROOTS` declares one.
- Values are passed through to the action exactly as the widget emits them
(`number` → number, `date` → `YYYY-MM-DD`, lookup → record id(s), `file` →
uploaded file descriptor(s); arrays when `multiple`).
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/console-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Navigation items can be conditionally hidden using expressions:
}
```

`ExpressionProvider` (`@object-ui/app-shell`) wraps the layout and provides an `ExpressionEvaluator` that resolves `${}` templates against context variables (`user`, `app`, `data`).
`ExpressionProvider` (`@object-ui/app-shell`) wraps the layout and provides an `ExpressionEvaluator` that resolves `${}` templates against context variables (`current_user` and its `user` / `ctx.user` / `os.user` aliases, `data`, `features`). It publishes `app` on the React context value for components to read, but does **not** bind it as an expression root — objectui#8155 removed that binding, because the engine's `SCOPE_ROOTS` has no `app`.

### 2. Action System

Expand Down
4 changes: 2 additions & 2 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ table at the end of the next section.
A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and
engine as the field-level rule (`string | { dialect?, source }`, a CEL
predicate over the live record, evaluated by `@objectstack/formula` with the
host predicate scope bound, so it can read `current_user` / `app` / `data` /
`features` exactly as a field rule can). Like every conditional rule in this
host predicate scope bound, so it can read `current_user` / `data` /
`features` exactly as a field rule can — there is no `app` root, objectui#8155). Like every conditional rule in this
system it **fails open**: a predicate that cannot be evaluated leaves the tab
visible rather than hiding data behind a broken expression.

Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -656,13 +656,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// `positions` — so `'sales' in current_user.positions`, the gate the server
// enforces on write, faulted here rather than hiding the field.
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.
() => createExpressionEvaluator({
user: buildExpressionUser(user),
app: activeApp || {},
data: editingRecord || {},
features,
}),
[user, activeApp, editingRecord, features],
[user, editingRecord, features],
);

// objectui#5619 — `isWorkspaceAdminResolved` belongs in this readiness gate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,19 @@ describe('objectui#6493 — buildExpressionScope binds one user object under all
expect(scope.os.user).toBe(user);
});

it('binds app, data and features, and defaults every root to an empty object', () => {
it('binds data and featuresand NOT `app` — 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
// made this tier the only place it existed: advertised by the
// conditional-formatting editor and refused by the linter judging the very
// same field, with no spelling that did both.
//
// `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.
expect(scope).toStrictEqual({
current_user: {}, user: {}, ctx: { user: {} }, os: { user: {} }, app: {}, data: {}, features: {},
current_user: {}, user: {}, ctx: { user: {} }, os: { user: {} }, data: {}, features: {},
});
// The identity above holds for the defaults too — the hand-written fallback
// in `useExpressionContext` used to mint three separate empty objects.
Expand Down
56 changes: 47 additions & 9 deletions packages/app-shell/src/providers/ExpressionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ const ExprCtx = createContext<ExpressionContextValue | null>(null);
/** The inputs an app-shell surface has when it needs a predicate scope. */
export interface ExpressionScopeInput {
user?: Record<string, any>;
app?: Record<string, any>;
/**
* ⛔ No `app`. It is not an input to the predicate scope, because the scope
* does not bind it — objectui#8155, ruled 2026-09-07. Accepting an argument
* this builder then discards is the declared-but-not-enforced shape the same
* 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.
*/
data?: Record<string, any>;
features?: Record<string, any>;
}
Expand Down Expand Up @@ -77,15 +84,36 @@ export interface ExpressionScopeInput {
* spec`'s `page.zod.ts` documents for component `visibleWhen` ("the shipping
* renderer additionally mounts `app`, `features`, `os.user` … renderer
* behaviour, NOT contract-guaranteed"). It is bound here because it is what
* THIS tier's own diagnostic advice tells an author they may name.
* THIS tier's own diagnostic advice tells an author they may name. That quote
* still names `app`; this tier no longer mounts it — see below.
*
* ## Why there is no `app` root (objectui#8155, ruled 2026-09-07)
*
* There was one, and it was a root the protocol never declared. ADR-0068
* declares `current_user` with the `user` / `ctx.user` aliases and nothing
* named `app`; `@objectstack/formula`'s `SCOPE_ROOTS` (`cel-engine.ts`) has no
* `app` either. So an authored `app.name == 'crm'` was bound HERE and refused
* by the engine that lints it — an editor advertising a root its own linter
* rejects, with the nonsense remedy `record.app` and no spelling that both
* lints clean and resolves.
*
* 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.
*
* 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.
*/
export function buildExpressionScope({
user = {},
app = {},
data = {},
features = {},
}: ExpressionScopeInput = {}): Record<string, any> {
return { current_user: user, user, ctx: { user }, os: { user }, app, data, features };
return { current_user: user, user, ctx: { user }, os: { user }, data, features };
}

/**
Expand All @@ -110,7 +138,9 @@ interface ExpressionProviderProps {

export function ExpressionProvider({ children, user = {}, app = {}, data = {}, features = {} }: ExpressionProviderProps) {
const value = useMemo(() => {
const evaluator = createExpressionEvaluator({ user, app, data, features });
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.
return { user, app, data, features, evaluator };
}, [user, app, data, features]);

Expand All @@ -120,8 +150,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, app, data, features }),
[user, app, data, features],
() => buildExpressionScope({ user, data, features }),
[user, data, features],
);

return (
Expand All @@ -142,8 +172,16 @@ export function useExpressionContext(): ExpressionContextValue {
// Through the same builder: the hand-written version gave `current_user`,
// `ctx.user` and `os.user` three DIFFERENT empty objects, which ADR-0068 D1
// spells as aliases "pointing at the same object".
const fallback = { user: {}, app: {}, data: {}, features: {} };
return { ...fallback, evaluator: createExpressionEvaluator(fallback) };
//
// 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.
// 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) };
}
return ctx;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,16 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
// faulted here and failed OPEN while resolving normally on a nav item.
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.
createExpressionEvaluator({
// expressionUser already handles the anonymous fallback, so we can
// pass it through unconditionally.
user: expressionUser,
app: { name: appName },
data: {},
features,
}),
[expressionUser, appName, features],
[expressionUser, features],
);

// Resolve the field list using the same visibility-aware logic as the
Expand Down
Loading
Loading