Repository navigation
fix(components,plugin-detail,react): permission-shaped action gates fail closed while the permissions payload has not loaded (objectui#11212) - #11237
Conversation
…ail closed while the permissions payload has not loaded (objectui#11212) Rider 1 of objectui#4421 on the legs that failed soft: - action:group (inline member, dropdown member, the group's own gate), action:icon and the related-list toolbar evaluate `visible` with `throwOnError`, as action:button / action:menu / action:bar already did: a predicate that faults hides the action and is reported once. Per key, not a can() special case. - page:header resolves a faulting `disabled` to DISABLED (fallback true); `visible` and `hidden` keep their fallbacks. - useActionEngine binds the predicate scope's subject as `current_user` on the runner bag it filters `visible` against, so record:quick_actions answers current_user.can(...) granted / denied / not loaded as shown / hidden / hidden instead of hidden in every state. Three-state pin: app-shell currentUserCan-failClosed-11212.render.test.tsx. The pins that recorded the old fail-soft answers are flipped. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…e added `useMemberVisible` takes the record context as `Record<string, unknown>`, and the shared-runner bind passes `bound` uncast (it is already a `Partial<ActionContext>`). Lint warnings per touched file are back to the base counts. 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
|
Contract reviewServed-tier: Inputs: card objectui#11212 (body and its 4 comments — the added leg ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
…ublishes The headline said the gated action stays hidden or disabled "on every action surface". For `disabled` that overclaims: only `page:header`'s disabled leg moved. The headline now names the `visible` surfaces and the `page:header` `disabled` leg, which are the two the diff changes. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
✅ 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. The record of record on the previous head is ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #11212
Clause-②: yes
Rider 1 of objectui#4421 on the legs that still answered SHOWN or ENABLED while the permissions payload had not loaded. The ruling, verbatim: "permission-shaped bindings are fail-closed while the permissions payload has not loaded — the opposite of the predicate default — or the first-paint leak reappears. Pin this."
What changed
Measured through the real
MePermissionsProvider→ExpressionProvider→ActionProvider→ real renderers, one verb (delete), three states. Reproduced red onorigin/main(dded788ada) before any source edit.action:groupinline membervisibleaction:groupdropdown membervisibleaction:grouphostvisibleaction:iconvisibleRelatedToolbarButton)visiblerecord:quick_actionsvisible(ActionRunner bag)page:headerdisabled: !current_user.can(...)action:group(@object-ui/components): both leaves read one same-file hook,useMemberVisible, which evaluatesvisiblewiththrowOnError, asaction:menu'suseMenuActionVisibledoes. The group's ownvisibletakes the same option. A predicate that faults hides the action and is reported once, naming it.action:icon(@object-ui/components):visiblegetsthrowOnError, as onaction:button. The auto-trigger follows the same verdict, and its comment now names the fail-closed policy.RelatedToolbarButton(@object-ui/plugin-detail):visiblegetsthrowOnError. The header comment onpermittedToolbarActionsalready called this CEL "fail-CLOSED"; before this change that comment was wrong, and now it is right.page:headerdisabled(@object-ui/components,containers.tsx):evalHeaderPredicatetakes the fault fallback per key.visibleandhiddenstayfalse.disabledis nowtrue, so adisabledpredicate that faults renders the button disabled.useActionEngine(@object-ui/react): the hook binds the predicate scope's subject (usePredicateScope().current_user) ascurrent_useron the runner bag thatgetActionsForLocationfilters against. It is the same objectExpressionProviderpublishes, not a copy, because the engine answerscanonly for a receiver identical to the boundcurrent_user. It is bound on the standalone runner and on a shared provider runner, with or without per-render keys. The memo is keyed on the subject's serialisable fields and on its permissions map, not on the subject's identity (AGENTS.md [WIP] Enhance every detail of the designer #10). The runner'suser/ctx.user/os.userstay the host's object, because that object carries thesystemPermissionsthe runner's capability gate reads.The policy is per key, not a
can()special case. Avisiblethat faults on an unbound root (nope.deep == 1) is hidden on every leg above, and a headerdisabledthat faults is disabled. The pin checks both.The
disableddecision and its blast radiusRider 1 says the gate is closed while the payload has not loaded. For a
disabledgate, closed means DISABLED. Measured facts behind the decision:disabledfail direction is not decided inevalRowPredicate(listConditional.ts). That function returns whateverfallbackits caller passes. The header'sevalHeaderPredicatepassedfalsefor all three keys, and ondisabledthat value means ENABLED. The fix is in the caller, andlistConditional.tsis untouched.page:header'sresolveDisabledgoes through that leg: the inline buttons and the⋯overflow items of authored header actions. Built-insys_edit/sys_deletearrive with a booleandisabledthatRecordDetailViewcomputes itself, so this change does not touch them.page:headeraction whosedisabledpredicate faults, whatever the cause. That includes a misspelled field, a retireddata.*or bare spelling, an unbound root, andcan()before load. All of these now render disabled instead of enabled. This matches what the other surfaces already did.action:button,action:menu,action:group,action:iconandrecord:quick_actionsevaluatedisabledfail-soft totrue, which on this key is DISABLED.ActionRunner.executerefuses a faultingdisabledwith "Action is disabled". So on the header, a faultingdisabledrendered an enabled button that the runner then refused.disabled(RowActionMenu, measured ENABLED / enabled / disabled fordisabled: !current_user.can(...)), the data-table row action'sdisabled, and the built-indisabledWhenpredicates (documented fail-soft since PR fix(plugin-detail): the record detail header honors userActions predicates (#4419) #4515). These are other packages or other keys, so they are reported as a fork in the dev report, not changed here.Mechanism notes (PM assumptions measured)
dded788adaafter PR fix(components): action:menu and action:group take the host's disabled by name (objectui#11182) #11221: red first, 14 of 28 arms.throwOnErrorhides and warns once per (label, predicate), as assumed.action:groupgot a small same-file hook instead of two copies of the option, which mirrorsaction:menu.visibleleg.DashboardRendererregisters defs that carry onlyname/type/target/label, and@objectstack/spec's dashboard header action is a strict object oflabel/actionUrl/actionType/icon. Socurrent_user.can(...)cannot be authored there, andrecord:quick_actionsis the onlyvisiblesurface on the ActionRunner bag. The binding still reaches the dashboard's runner, where it gates nothing today.ActionRunner.tsis untouched. The binding happens where React can read the predicate scope, inuseActionEngine.Reach
On the console's app routes the not-loaded state does not render, because
MePermissionsProviderholds a loading screen./forms/:nameis a sibling route inApp.tsx, outside that provider. A throwaway probe (not committed) rendered the realAppat/forms/showcase_task.edit, withFormPagereplaced by anaction:icongated oncurrent_user.can('account', 'delete')and an ungated companion. It readisLoaded=false subjectCarriesPermissions=false gatedIcon=hidden companion=shownat this branch's head. With theaction:iconfix ablated, the same probe readgatedIcon=SHOWN.Tests
packages/app-shell/src/providers/__tests__/currentUserCan-failClosed-11212.render.test.tsx: 7 legs × 3 states, plus 7 per-key unbound-root arms, 28 tests. Every arm has an ungated companion and its own action name, because the fault reports are warn-once per locator.action-record-predicate-root.test.tsx: theaction:iconandaction:groupvisiblefault and retired-spelling cases, which now read hidden.page-header-predicate-dialect.test.tsx: a faultingdisablednow reads DISABLED, reported once.action-template-predicate-gate.test.tsx: only thefailClosedsite flags and labels changed; the assertions did not.related-toolbar-visible.test.tsx: new faulting-predicate case.8d44406f79with the fix committed first. Mutations went throughablation-replace.mjs:throwOnError: true→falseinaction-group.tsx(×2),action-icon.tsxandRelatedList.tsx; the headerdisabledfallbacktrue→false;useActionEnginebinding nothing. Predicted before the run: 25 red. Result:Tests 25 failed | 123 passed (148). The 25 red were:record:quick_actionsGRANTED, and DENIED through its silence assertion.action-record-predicate-root.page-header-predicate-dialect.related-toolbar-visible.git diff HEADwas empty.b8d647a3c4. The union ran after the last commit on a clean tree. Heavy runs went through the shared verify lock, and the verdicts quoted are the tools' own lines:pnpm exec vitest run packages/components/ packages/react/:Test Files 447 passed | 1 skipped (448),Tests 4758 passed | 24 skipped, exit 0.pnpm exec vitest run packages/plugin-detail/ packages/app-shell/src/providers/__tests__/ packages/core/src/actions/ packages/core/src/evaluator/plus the objectui#4421 permissions pin, plus 98 consumer test files outside those trees:Test Files 384 passed | 1 skipped (385),Tests 6177 passed | 35 skipped, exit 0. The consumer files are every test outside components / react / plugin-detail that namespage:header,action:group,action:icon,RelatedList,quick_actionsoruseActionEngine.turbo run build --filter='@object-ui/app-shell^...' --concurrency=2,Tasks: 28 successful, 28 total.type-checkfor@object-ui/components,@object-ui/react,@object-ui/plugin-detailand@object-ui/app-shellexited 0 on all four.tsc -p tsconfig.test.json --listFilesOnlyconfirms the five edited or new test files are in those programs.node scripts/check-changeset-presence.mjsexit 0 (11 source files of 4 released packages, 1 changeset).pnpm check:control-bytesexit 0.pnpm check:action-forward-parityexit 0.pnpm check:new-line-citationsexit 0, 0 new citations.pnpm check:doc-typesexit 0.pnpm check:changeset-claimsexit 0 (report-only). It named 7 pending changesets that cite the touched files; I read each paragraph, and none describes a fault policy.eslint .over the 11 touched files. Errors are 0. Warnings per file equal the base counts; the one exception is the new pin, which has 2.Acceptance notes (not filed)
RelatedToolbarButtongates on truthiness (visiblePred && !isVisible), so a toolbar action authoredvisible: falserenders. A probe measured SHOWN.RelatedRecordActionsBridge'sderiveActionspassesvisiblethrough unfiltered. This is the objectui#3812 declared-gate class, not this card's fault-policy class, so it is left untouched. Carrier: none.ActionRunner.execute'sdisabledgate comment says a fault "defaults to NOT-disabled". The code blocks, which is the direction this change rules fordisabled. So only the comment is wrong. It was recorded as a neighbour on PR feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) #11208, and it is untouched here.current_useronly throughuseActionEngine. Because that hook merges into a shared provider runner, a page with arecord:quick_actionsblock also bindscurrent_userfor the provider'sexecutegates, the same way it already bindsrecord/recordId/objectName. The ActionProvider-level binding stays open under the PR feat(app-shell,core,permissions): an action predicate can ask current_user.can(object, verb) once the permissions payload has loaded (objectui#4421) #11208 neighbour note.Generated by Claude Code