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
58 changes: 58 additions & 0 deletions .changeset/8166-record-scope-data-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@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
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. `@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.
14 changes: 12 additions & 2 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,26 @@ 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
// 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.
//
// ⛔ 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.
Expand Down
86 changes: 71 additions & 15 deletions packages/app-shell/src/providers/ExpressionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
features?: Record<string, any>;
}

Expand Down Expand Up @@ -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<string, any> {
return { current_user: user, user, ctx: { user }, os: { user }, data, features };
return { current_user: user, user, ctx: { user }, os: { user }, features };
}

/**
Expand All @@ -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]);

Expand All @@ -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 (
Expand All @@ -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;
}
Expand Down
Loading
Loading