feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) - #11208
Conversation
… into the predicate scope once permissions have loaded (objectui#4421) The acting subject carries the loaded /auth/me/permissions objects map under a symbol; evalFieldPredicate hands it to the engine as EvalContext.permissions. Not loaded: nothing is bound and the engine refuses can() loudly, so each surface applies its own fault policy. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…tes per surface family and document it as client-only UI gating (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…s (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…jectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…e is measured, not inherited from warn-once (objectui#4421) Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Generated by Claude Code |
Contract reviewServed-tier: Inputs read: card #4421 (body and all 10 comments, rulings ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged against the rulings and the engine it wires to.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Delta record. This head is the GitHub update-branch merge of ① Derived judgmentsCarried over from record
So every judgment in ① of record ② Semver levelCarried over from record ③ Boundary flags
Implemented-by: VERDICT: PASS |
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/disabledpredicate can now askcurrent_user.can(object, verb)and get the caller's object-permission verdict from the loaded/auth/me/permissionspayload — the card's case: an app turns the built-in Delete off, ships a logical delete in its place, and gates it withvisible: current_user.can('account', 'delete').The engine half landed as
@objectstack/formula@17.5.0(dyn.can(dyn, dyn), answered fromEvalContext.permissions, receiver compared by identity with the boundcurrent_user, loud refusal when the context carries no permission data). This PR is the wiring, through ONE seam:@object-ui/permissions— the permission context carrieseffectiveObjects: the response'sobjectsmap verbatim, orundefinedwhen the provider holds no such response (the role-basedPermissionProvider, or no provider).undefinedand{}stay distinct.@object-ui/core—evalFieldPredicate, the one place every action surface reaches the CEL engine, hands the acting subject's permissions toExpressionEngine.evaluateaspermissions. The map rides on the subject object under a symbol (bindSubjectPermissions/subjectPermissionsOf, new fileevaluator/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 andExpressionContext. No surface got its own copy of the hand-off.@object-ui/app-shell—ExpressionProviderreadsusePermissions()itself and binds the map only whileisLoadedis true (useExpressionPermissions, which adapts the payload through the formula package'stoEvalPermissionsonce per payload object, outside React). One subject object undercurrent_user/user/ctx.user/os.user, the one carrying the map. The two imperative field-visibility evaluators (AppContent's record-form modal andRecordFormPage) take the same input, so one predicate answers the same under the provider and beside it (objectui#6493's one-bag rule).@objectstack/formulafloor raised to^17.5.0in@object-ui/coreand@object-ui/app-shell— app-shell now callstoEvalPermissions, which does not exist before 17.5.0. The lockfile moves only the twospecifierlines; the resolved version was already 17.5.0.content/docs/layout/page-header.mdxgains "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/4421-current-user-can-binding.md(minor on the three packages).Zone 2 — the PM's mechanism assumptions, measured
node_modules/@objectstack/formula(17.5.0dist):registerPermissionPredicateregistersdyn.can(dyn, dyn): bool; subject =buildScope(ctx).current_user(whichextracan supply); verbs via the spec'sresolveObjectPermissionVerb; nopermissionsin the context ⇒ throws (ok: false,kind: 'runtime', message names the missing input). A probe throughextrawith a symbol-bearing subject: grantedtrue, deniedfalse, missing map ⇒ fault, all four aliases identical, a COPY of the subject as receiver ⇒ refused. Holds.MePermissionsProviderholds it but did NOT expose theobjectsmap (only verdict functions). Falsified in part: the producer neededeffectiveObjects; theisLoadedgate is applied as assumed.usePermissionsdefault — the binding never callsusePermissions().can; it readseffectiveObjects, which the no-provider answer does not carry. Measured in the render pin: with no provider,usePermissions().can('account','delete')answerstruewhile the bound action is hidden. Changing the global default would move no binding verdict, so Rider 1 does not need it. Left untouched (usePermissions.tsis not in this diff); the ruling-text conflict is recorded in the report for the seat.visiblepredicates 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 outsideMePermissionsProvideris in permanently), loaded-granted, loaded-denied. RealMePermissionsProvider+ realExpressionProvider+ the real surface.visible(evalRowPredicate,fallback: false)visible(page:header)action:buttonvisible(useCondition,throwOnError)action:buttondisabled: !can(…)(fail-soft, fallbacktrue)record:quick_actions/ActionEnginecontextaction:groupinlinevisible(fail-soft)action:iconvisible(fail-soft)visible(RelatedToolbarButton, fail-soft)disabled: !can(…)(fallback: false)action:group/action:iconlive inpackages/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.MePermissionsProvidershows its loading screen until the first answer, and after itisLoadedstays 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/:nameroute (InternalFormRoutemounts its ownExpressionProvider), and standalone embeds.ActionEnginerow: its context is theActionProvidercontext, which bindsuser/ctx.userbut nevercurrent_user, socanis refused in every state — it never leaks, and it never answers. Out of this card's claimed surface; reported.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 (afterturbo 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)".console.erroris 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 fromfieldRules.ts; "ok mutation landed: anchor 1 to 0, blob 4028931a8fdc 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 (4028931a8fdc) andgit diff HEADis 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
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 twopackage.jsonfloors plus two lockfilespecifierlines.components/renderers/action/**) and objectui#11186 (app-shell/src/hooks/**,layout/**) — this diff touches neither set.Acceptance notes
useConditionleg reports only "its predicate threw — CEL predicate failed to evaluate: SOURCE", dropping the engine's reason (here "carries no permission data"); and its line printsPredicate: [object Object]for an envelope. TheevalRowPredicatefamily forwards the engine reason. Diagnostics only; no verdict moves.objectPermissionGrants:=== true, absent object ⇒false, super-user bits folded,exportneedsallowExport), while the client'sMePermissionsProvider.checkreads!== false, falls back to a'*'entry, and mapsexporttoallowRead. 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.isLoadedis the gate, as the dispatch assumed: during a refetchMePermissionsProviderstill holds its previous map withisLoadedfalse, so acan-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