Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 9cebfca

Browse files
objectstack-fleet[bot]claudeos-warren
authored
feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) (#11208)
Part of #4421 — the objectui half: the binding, its pins and the Rider 2 wording. What stays open on the card is Rider 1 on the fail-soft legs measured below (a decision for the seat, see "Rider 1 — measured per leg"). Server parity is objectstack#18783's. Clause-②: yes ## What this does An action's `visible` / `disabled` predicate can now ask `current_user.can(object, verb)` and get the caller's object-permission verdict from the loaded `/auth/me/permissions` payload — the card's case: an app turns the built-in Delete off, ships a logical delete in its place, and gates it with `visible: current_user.can('account', 'delete')`. The engine half landed as `@objectstack/formula@17.5.0` (`dyn.can(dyn, dyn)`, answered from `EvalContext.permissions`, receiver compared by identity with the bound `current_user`, loud refusal when the context carries no permission data). This PR is the wiring, through ONE seam: - **`@object-ui/permissions`** — the permission context carries `effectiveObjects`: the response's `objects` map verbatim, or `undefined` when the provider holds no such response (the role-based `PermissionProvider`, or no provider). `undefined` and `{}` stay distinct. - **`@object-ui/core`** — `evalFieldPredicate`, the one place every action surface reaches the CEL engine, hands the acting subject's permissions to `ExpressionEngine.evaluate` as `permissions`. The map rides on the subject object under a symbol (`bindSubjectPermissions` / `subjectPermissionsOf`, new file `evaluator/subjectPermissions.ts`): every key of the scope bag is a CEL root and the engine keeps this map out of the variable namespace, while the subject is the one object every bag already carries by reference under all four aliases, through every spread and `ExpressionContext`. No surface got its own copy of the hand-off. - **`@object-ui/app-shell`** — `ExpressionProvider` reads `usePermissions()` itself and binds the map only while `isLoaded` is true (`useExpressionPermissions`, which adapts the payload through the formula package's `toEvalPermissions` once per payload object, outside React). One subject object under `current_user` / `user` / `ctx.user` / `os.user`, the one carrying the map. The two imperative field-visibility evaluators (`AppContent`'s record-form modal and `RecordFormPage`) take the same input, so one predicate answers the same under the provider and beside it (objectui#6493's one-bag rule). - `@objectstack/formula` floor raised to `^17.5.0` in `@object-ui/core` and `@object-ui/app-shell` — app-shell now calls `toEvalPermissions`, which does not exist before 17.5.0. The lockfile moves only the two `specifier` lines; the resolved version was already 17.5.0. - **Docs (Rider 2)** — `content/docs/layout/page-header.mdx` gains "Gating an action on the caller's object permissions", ending with the scope sentence: client-side UI gating only, not an authorization boundary, the server still answers 403; server-side evaluation is objectstack#18783. - Changeset `.changeset/4421-current-user-can-binding.md` (minor on the three packages). ## Zone 2 — the PM's mechanism assumptions, measured 1. **Installed engine** — read in `node_modules/@objectstack/formula` (17.5.0 `dist`): `registerPermissionPredicate` registers `dyn.can(dyn, dyn): bool`; subject = `buildScope(ctx).current_user` (which `extra` can supply); verbs via the spec's `resolveObjectPermissionVerb`; no `permissions` in the context ⇒ throws (`ok: false`, `kind: 'runtime'`, message names the missing input). A probe through `extra` with a symbol-bearing subject: granted `true`, denied `false`, missing map ⇒ fault, all four aliases identical, a COPY of the subject as receiver ⇒ refused. **Holds.** 2. **Client already holds the payload** — `MePermissionsProvider` holds it but did NOT expose the `objects` map (only verdict functions). **Falsified in part**: the producer needed `effectiveObjects`; the `isLoaded` gate is applied as assumed. 3. **Rider 1 at the binding via each surface's fault policy** — **falsified for some legs**, table below. 4. **Global `usePermissions` default** — the binding never calls `usePermissions().can`; it reads `effectiveObjects`, which the no-provider answer does not carry. Measured in the render pin: with no provider, `usePermissions().can('account','delete')` answers `true` while the bound action is hidden. Changing the global default would move no binding verdict, so Rider 1 does not need it. **Left untouched** (`usePermissions.ts` is not in this diff); the ruling-text conflict is recorded in the report for the seat. 5. **Rider 2 home** — the page that documents header-action `visible` predicates and the identity scope (`page-header.mdx`); the section also covers the row menu. ## Rider 1 — measured per leg Three states on one verb (`delete`): not loaded (no permission provider — the state a mount outside `MePermissionsProvider` is in permanently), loaded-granted, loaded-denied. Real `MePermissionsProvider` + real `ExpressionProvider` + the real surface. | surface / leg | not loaded | granted | denied | pinned here | |---|---|---|---|---| | row menu `visible` (`evalRowPredicate`, `fallback: false`) | hidden | shown | hidden | yes | | record header `visible` (`page:header`) | hidden | shown | hidden | yes | | `action:button` `visible` (`useCondition`, `throwOnError`) | hidden | shown | hidden | yes | | `action:button` `disabled: !can(…)` (fail-soft, fallback `true`) | disabled | enabled | disabled | no (measured) | | `record:quick_actions` / `ActionEngine` context | hidden | **hidden** | hidden | no (measured) | | `action:group` inline `visible` (fail-soft) | **shown** | shown | hidden | no (measured) | | `action:icon` `visible` (fail-soft) | **shown** | shown | hidden | no (measured) | | related-list toolbar `visible` (`RelatedToolbarButton`, fail-soft) | **shown** | shown | hidden | no (measured) | | record header `disabled: !can(…)` (`fallback: false`) | **enabled** | enabled | disabled | no (measured) | - The bold "not loaded" cells are the legs where Rider 1 does not hold: they apply their existing fail-soft fault policy to the engine's refusal. `action:group` / `action:icon` live in `packages/components/src/renderers/action/**`, serial behind objectui#11185; the others would need a fault-policy change for every faulting predicate on the leg, wider than "fail closed on the no-permissions error". Neither was done here. - Reachability: on the console's app routes the not-loaded state does not render — `MePermissionsProvider` shows its loading screen until the first answer, and after it `isLoaded` stays true (its refetch effect keys on values that do not change). It is reachable where a predicate scope is published outside that provider: the `/forms/:name` route (`InternalFormRoute` mounts its own `ExpressionProvider`), and standalone embeds. - The `ActionEngine` row: its context is the `ActionProvider` context, which binds `user` / `ctx.user` but never `current_user`, so `can` is refused in every state — it never leaks, and it never answers. Out of this card's claimed surface; reported. - The measurement for the rows marked "measured" was a throwaway probe, not committed: pinning a fail-open leg as expected would fossilize it. ## Tests Head of the runs: `d4bf561239`. Commands from the worktree root, each through the shared verify lock; verdicts are the tools' own lines. - `pnpm exec vitest run packages/app-shell/src/providers/__tests__/currentUserCan-4421.render.test.tsx packages/core/src/evaluator/__tests__/subjectPermissions-4421.test.ts` — `Tests 25 passed (25)`. - `pnpm exec vitest run packages/permissions/ packages/core/` — `Test Files 201 passed (201)`, `Tests 3882 passed | 27 skipped (3909)`. - `pnpm exec vitest run packages/app-shell/` in shards — see the report for the per-shard verdicts and which shards ran. - `pnpm --filter @object-ui/core type-check` / `@object-ui/permissions` / `@object-ui/app-shell` — exit 0 each (after `turbo run build --filter="@object-ui/app-shell^..."`, 28 tasks successful). - `node scripts/check-changeset-presence.mjs` — exit 0, "12 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)". - `pnpm check:control-bytes` — OK. `pnpm check:doc-types` — "Every documented component type is registered." `pnpm check:doc-snippets` — "679 of 679 block(s) judged, 0 failed" (after its scoped build). `pnpm check:new-line-citations` — "0 new citation(s)". - No user-facing i18n string was added (the one new `console.error` is a developer diagnostic), so the i18n gates were not run. **Reverse verification (ablation), direction predicted in the pin's docblock before running.** Committed first; the mutation went through `ablation-replace.mjs` (anchor `...(permissions !== undefined ? { permissions } : {}),` deleted from `fieldRules.ts`; "ok mutation landed: anchor 1 to 0, blob 4028931 to da7c89d41dab"). Result: `Tests 14 failed | 11 passed (25)` — every GRANTED arm and every DENIED arm red (the denied arms through their "nothing reported" assertion; hidden either way), every NOT-LOADED arm green, as predicted. Restore: "ok restored: blob == HEAD (4028931) and `git diff HEAD` is empty"; the pins re-ran green after (`Tests 25 passed (25)`). The first ablation run disagreed with the prediction on the DENIED arms, and the reason is worth keeping: every fault report on these surfaces is warn-once per (locator, predicate), so with one action name across arms the not-loaded arm's report silenced the same report in the denied arm, and its "nothing reported" assertion passed for a reason unrelated to the verdict. The fix was one action name per arm; the second run is the one quoted above. ## Surface notes - Beyond the claim's listed files: `RecordFormPage.tsx` (the same builder's second imperative caller, one-bag rule), `packages/permissions/src/{PermissionContext,MePermissionsProvider,PermissionProvider}` (the producer of the map — the claim said "if the real producer is elsewhere, fix it there"), and the two `package.json` floors plus two lockfile `specifier` lines. - In-flight overlap: objectui#11185 (`components/renderers/action/**`) and objectui#11186 (`app-shell/src/hooks/**`, `layout/**`) — this diff touches neither set. ## Acceptance notes - Not filed, recorded: the throwing `useCondition` leg reports only "its predicate threw — CEL predicate failed to evaluate: SOURCE", dropping the engine's reason (here "carries no permission data"); and its line prints `Predicate: [object Object]` for an envelope. The `evalRowPredicate` family forwards the engine reason. Diagnostics only; no verdict moves. - Not filed, recorded: the engine answers from the server's enforcement fold (`objectPermissionGrants`: `=== true`, absent object ⇒ `false`, super-user bits folded, `export` needs `allowExport`), while the client's `MePermissionsProvider.check` reads `!== false`, falls back to a `'*'` entry, and maps `export` to `allowRead`. On a payload that materialises every registered object with explicit booleans the two agree; on a hand-built payload they can differ. Which one the built-in buttons should follow is outside this card. - `isLoaded` is the gate, as the dispatch assumed: during a refetch `MePermissionsProvider` still holds its previous map with `isLoaded` false, so a `can`-gated action hides for that window while the built-in buttons keep answering from the old map. In the console that refetch does not occur today. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Warren Buffett <warren@objectstack.ai>
1 parent cfc9b6d commit 9cebfca

17 files changed

Lines changed: 838 additions & 12 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/permissions': minor
4+
'@object-ui/app-shell': minor
5+
---
6+
7+
feat: an action's `visible` / `disabled` predicate can ask `current_user.can(object, verb)` — the caller's object permissions, from the payload the built-in Edit / Delete buttons are already gated by
8+
9+
A custom action that replaces a built-in CRUD button — a logical delete that archives
10+
instead of deleting, say — can now carry the same gate the built-in button had:
11+
12+
```yaml
13+
visible: current_user.can('account', 'delete')
14+
```
15+
16+
It answers from the signed-in user's `/auth/me/permissions` payload once that payload
17+
has loaded, on the record header and the row menu alike. Until then it gives no answer:
18+
the predicate faults, and a surface that evaluates `visible` fail-closed does not render
19+
the action. The `user` / `ctx.user` / `os.user` aliases are the same call.
20+
21+
This is client-side UI gating only. It decides whether the button is shown; the server
22+
still enforces object permissions on the request the action sends and answers 403 when
23+
they are not held.
24+
25+
- `@object-ui/permissions`: the permission context carries `effectiveObjects`, the
26+
response's `objects` map verbatim (`undefined` when the provider holds no such
27+
response — the role-based `PermissionProvider`, or no provider at all).
28+
- `@object-ui/core`: `evalFieldPredicate` hands the acting subject's permissions to the
29+
CEL engine as `EvalContext.permissions`; `bindSubjectPermissions` /
30+
`subjectPermissionsOf` are the carrier. `@objectstack/formula` is now required at
31+
`^17.5.0`, the first release that answers `can`.
32+
- `@object-ui/app-shell`: `ExpressionProvider` binds the map into the predicate scope it
33+
publishes only while `usePermissions().isLoaded` is true, and the record-form field
34+
evaluators take the same input, so one predicate answers the same everywhere.

‎content/docs/layout/page-header.mdx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,44 @@ The identity side of the comparison is `os.user` — the spec's canonical CEL id
133133
scope, and the same one the server binds — with `current_user`, `user` and `ctx.user`
134134
available as aliases of it.
135135

136+
## Gating an action on the caller's object permissions
137+
138+
`current_user.can(object, verb)` answers "may the signed-in user do *verb* on *object*"
139+
from the caller's effective object permissions — the `/auth/me/permissions` payload the
140+
console already holds, the one its built-in Edit and Delete buttons are gated by. It is
141+
the gate to write when a custom action replaces a built-in one, for example a logical
142+
delete that archives the record instead of removing it:
143+
144+
```yaml
145+
actions:
146+
- name: account_void
147+
label: Void
148+
locations: [record_header, list_item]
149+
visible: current_user.can('account', 'delete')
150+
```
151+
152+
The row menu (`list_item`) evaluates the same predicate against the same scope, so one
153+
declaration gates both places.
154+
155+
- `object` is the object's API name. `verb` must come from the closed verb table
156+
`OBJECT_PERMISSION_VERBS` in `@objectstack/spec/security`; a verb outside it is a fault,
157+
not a `false`.
158+
- Call it on the signed-in user. `user.can(…)`, `ctx.user.can(…)` and `os.user.can(…)`
159+
are the same call; a bare `can(…)`, or `record.can(…)`, is a fault.
160+
- Until the permissions payload has loaded there is no answer: the call faults, and a
161+
header action whose `visible` faults is not rendered (see above). Gate on `visible`,
162+
not `disabled` — a `disabled` predicate that faults leaves the action enabled.
163+
- `requiredPermissions` is a different gate: it names system capabilities, which the
164+
platform action route enforces as well as the UI. `current_user.can` asks about
165+
object permissions and does nothing on the server.
166+
167+
**Client-side UI gating only.** This first phase is client-side: `current_user.can` decides
168+
whether the button is shown, nothing more. It is not an authorization boundary — the
169+
server still enforces the caller's object permissions on the request the action sends,
170+
and answers 403 when they are not held. Server-side evaluation of the same call (formula
171+
fields, validation rules, row-level security) is tracked separately in objectstack#18783;
172+
until it lands, do not rely on `can` in an expression the server evaluates.
173+
136174
## Layout
137175

138176
The renderer picks one of two layouts when it renders:

‎packages/app-shell/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"@object-ui/providers": "workspace:*",
8484
"@object-ui/react": "workspace:*",
8585
"@object-ui/types": "workspace:*",
86-
"@objectstack/formula": "^17.0.0",
86+
"@objectstack/formula": "^17.5.0",
8787
"@objectstack/lint": "^17.0.0",
8888
"@objectstack/spec": "^17.5.0",
8989
"@sentry/react": "^10.70.0",

‎packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
ExpressionProvider,
2727
createExpressionEvaluator,
2828
isObjectFieldVisible,
29+
useExpressionPermissions,
2930
} from '../providers/ExpressionProvider.js';
3031
import { buildExpressionUser } from '../providers/expressionUser.js';
3132
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
@@ -692,6 +693,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
692693
// object) and `features`. Its `user` was hand-rolled too, without
693694
// `positions` — so `'sales' in current_user.positions`, the gate the server
694695
// enforces on write, faulted here rather than hiding the field.
696+
// objectui#4421 — the same `permissions` input the provider below reads, so
697+
// `current_user.can(...)` in a field's `visibleWhen` answers here exactly as
698+
// it does under the provider (one bag, objectui#6493).
699+
const expressionPermissions = useExpressionPermissions();
695700
const expressionEvaluator = useMemo(
696701
// ⛔ No `app`: objectui#8155 removed it from the predicate scope, because
697702
// neither ADR-0068 nor the engine's `SCOPE_ROOTS` declares such a root.
@@ -709,8 +714,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
709714
() => createExpressionEvaluator({
710715
user: buildExpressionUser(user),
711716
features,
717+
permissions: expressionPermissions,
712718
}),
713-
[user, features],
719+
[user, features, expressionPermissions],
714720
);
715721

716722
// objectui#5619 — `isWorkspaceAdminResolved` belongs in this readiness gate

‎packages/app-shell/src/providers/ExpressionProvider.tsx‎

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
*/
1616

1717
import React, { createContext, useContext, useMemo } from 'react';
18-
import { ExpressionEvaluator } from '@object-ui/core';
18+
import { ExpressionEvaluator, bindSubjectPermissions } from '@object-ui/core';
1919
import { PredicateScopeProvider, reportUnresolvableVisibilityPredicate } from '@object-ui/react';
20+
import { usePermissions } from '@object-ui/permissions';
21+
import { toEvalPermissions, type EvalPermissions } from '@objectstack/formula';
2022

2123
export interface ExpressionContextValue {
2224
/** Current authenticated user */
@@ -59,6 +61,16 @@ export interface ExpressionScopeInput {
5961
* and still publishes it on the React context value.
6062
*/
6163
features?: Record<string, any>;
64+
/**
65+
* The caller's effective object permissions — the data
66+
* `current_user.can(object, verb)` is answered from (objectui#4421) — or
67+
* `undefined` while there is no LOADED payload to answer from. Take it from
68+
* {@link useExpressionPermissions}, which is what decides "loaded"; ⛔ never
69+
* pass `{}` for "not loaded yet": an empty map is a real answer ("holds
70+
* nothing"), and the engine refuses loudly precisely so that a missing
71+
* payload cannot pass for one.
72+
*/
73+
permissions?: EvalPermissions;
6274
}
6375

6476
/**
@@ -169,8 +181,81 @@ export interface ExpressionScopeInput {
169181
export function buildExpressionScope({
170182
user = {},
171183
features = {},
184+
permissions,
172185
}: ExpressionScopeInput = {}): Record<string, any> {
173-
return { current_user: user, user, ctx: { user }, os: { user }, features };
186+
// ONE subject object under all four spellings, and it is the one carrying the
187+
// permissions: the engine answers `can` only for a receiver IDENTICAL to the
188+
// bound `current_user`, so a second copy under any alias would be refused.
189+
const subject = bindSubjectPermissions(user, permissions);
190+
return { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features };
191+
}
192+
193+
/**
194+
* One adapted map per payload object, kept outside React (AGENTS.md #10): the
195+
* provider publishes the SAME `objects` object for the same response, so this
196+
* is keyed on the payload, never on a memoised identity. `null` records a
197+
* payload `toEvalPermissions` refused, so the refusal is reported once.
198+
*/
199+
const ADAPTED_PERMISSIONS = new WeakMap<object, EvalPermissions | null>();
200+
201+
function adaptEffectiveObjects(objects: object): EvalPermissions | undefined {
202+
let adapted = ADAPTED_PERMISSIONS.get(objects);
203+
if (adapted === undefined) {
204+
try {
205+
adapted = toEvalPermissions(objects);
206+
} catch (err) {
207+
// Refused, not repaired: the formula package's adapter has no lenient
208+
// arm, and neither does this one. The binding stays UNBOUND, so every
209+
// `current_user.can(...)` faults (and each surface applies its own fault
210+
// policy) instead of answering from a map that is not the contract.
211+
console.error(
212+
'[object-ui] current_user.can(object, verb) is not bound: the permissions '
213+
+ `payload was refused — ${err instanceof Error ? err.message : String(err)}`,
214+
);
215+
adapted = null;
216+
}
217+
ADAPTED_PERMISSIONS.set(objects, adapted);
218+
}
219+
return adapted ?? undefined;
220+
}
221+
222+
/**
223+
* The effective object permissions the predicate scope binds for
224+
* `current_user.can(object, verb)` (objectui#4421), or `undefined` when there
225+
* is no loaded payload to answer from.
226+
*
227+
* ## Rider 1: absent while not loaded, never "empty"
228+
*
229+
* The maintainer's ruling makes a permission-shaped binding fail-CLOSED while
230+
* the permissions payload has not loaded. The map is therefore handed on only
231+
* when `usePermissions().isLoaded` is true AND the provider holds an
232+
* `/auth/me/permissions` answer; otherwise nothing is bound and the engine
233+
* REFUSES `can` (`ok: false`, a fault naming the missing input). What happens
234+
* next is the evaluating surface's own FAULT policy, unchanged by this binding:
235+
* a leg that evaluates fail-closed (`throwOnError: true` on `useCondition`,
236+
* `fallback: false` on `evalRowPredicate`) hides the action, a fail-soft leg
237+
* does not. Which legs are which is deliberately not listed here (AGENTS.md
238+
* #9); `providers/__tests__/currentUserCan-4421.render.test.tsx` re-measures
239+
* the three states on the surfaces it pins. ⛔ No `{}` stand-in: it would
240+
* answer "holds nothing" — a quiet `false` indistinguishable from a real
241+
* denial, which the engine's contract forbids a caller to fabricate.
242+
*
243+
* `isLoaded` is the gate rather than "some map is present" because a
244+
* refetching `MePermissionsProvider` still holds its previous map while
245+
* `isLoaded` is false; the ruling's "has not loaded" is read as the provider's
246+
* own flag.
247+
*
248+
* ## Why the global no-provider default is not involved
249+
*
250+
* `usePermissions()` with no provider answers `can: () => true` (fail-open, the
251+
* built-in affordances' standalone-embed contract). This binding never calls
252+
* it: it reads `effectiveObjects`, which the no-provider answer does not carry,
253+
* so under no provider `current_user.can(...)` faults — it does not inherit
254+
* that `true`.
255+
*/
256+
export function useExpressionPermissions(): EvalPermissions | undefined {
257+
const { isLoaded, effectiveObjects } = usePermissions();
258+
return isLoaded && effectiveObjects ? adaptEffectiveObjects(effectiveObjects) : undefined;
174259
}
175260

176261
/**
@@ -194,22 +279,26 @@ interface ExpressionProviderProps {
194279
}
195280

196281
export function ExpressionProvider({ children, user = {}, app = {}, data = {}, features = {} }: ExpressionProviderProps) {
282+
// objectui#4421 — read HERE, once, so every mount of this provider (and every
283+
// surface under it) binds `current_user.can(...)` from the same payload with
284+
// no prop to thread and no per-surface copy of the hand-off.
285+
const permissions = useExpressionPermissions();
197286
const value = useMemo(() => {
198-
const evaluator = createExpressionEvaluator({ user, features });
287+
const evaluator = createExpressionEvaluator({ user, features, permissions });
199288
// `app` and `data` are still published on the context value — `DashboardView`
200289
// reads `app` as a plain value. Neither is handed to the evaluator:
201290
// objectui#8155 (`app`), objectui#8166 (`data`).
202291
return { user, app, data, features, evaluator };
203-
}, [user, app, data, features]);
292+
}, [user, app, data, features, permissions]);
204293

205294
// Also feed the predicate scope used by useCondition/useExpression in
206295
// @object-ui/react so action visibility predicates (e.g. on toolbar
207296
// buttons) can see deployment-level flags like features.multiOrgEnabled.
208297
// The SAME bag the evaluator above got — one builder, so the imperative and
209298
// the hook-driven halves of this provider cannot drift apart either.
210299
const scope = useMemo(
211-
() => buildExpressionScope({ user, features }),
212-
[user, features],
300+
() => buildExpressionScope({ user, features, permissions }),
301+
[user, features, permissions],
213302
);
214303

215304
return (

0 commit comments

Comments
 (0)