Mosaic one model, many lenses

ADR 0044 — Per-action requires: role gate

  • Status: accepted
  • Date: 2026-10-05
  • Slice: P44 (builder-roadmap: DSL-first, codegen-first, UI-first)

Context

P36 (ADR 0036) gave list pages row/toolbar actions bound to CQRS commands, and P40 (ADR 0040) gave the shell an advisory client-side role filter (filterNav, driven by /api/auth/me + MOSAIC_ROLE_LEVELS) that drops any [data-roles] element whose minimum role bar exceeds the caller's level. But an action could not declare a minimum role: every action button was rendered for every authenticated caller. The server-side command auth was still the real gate, so an under-privileged caller saw a button that would 403 on click — visible but unusable.

The pieces were already in place: P40's filter understands data-roles on arbitrary elements, and every app has a role ladder (app.role_levels, default mosaic:standard). What was missing was a DSL key to attach a minimum role to an action and a plan-time check that the role exists.

Decision

1. ui.actions[].requires (DSL)

A page action may now declare a minimum role:

ui:
  actions:
    - label: "activate"
      command: "RuleSet.Activate"
      placement: row
      confirm: "activate?"
      requires: editor   # a role on the app's role ladder

requires names a role that must exist on the app's role ladder. It is a client-side advisory declaration: it controls whether the button is shown, not whether the command runs. The command's own auth (its min_role/ permission/where, enforced server-side at dispatch) remains the final gate.

2. Plan-time validation (fail-closed)

The action's requires role is checked against ws.app.role_levels at plan time. An unknown role is the fail-closed ui-action-requires-role diagnostic ("… is not on the app's role ladder (declared: …)") and the action is dropped. The fail-closed posture follows from the filter's semantics: an unmatched role contributes no level to the bar, so its minimum stays at max and the element is hidden for everyone — a silent, always-hidden button is worse than a loud plan error.

The validated role rides on UiActionPlan.requires (carried through the action resolution unchanged; a plan diagnostic does not abort the build, it just skips that action).

3. Rendering (data-roles on the action button)

action_roles_attr(a) returns data-roles="<role>" when the action has a requires, else "". It is spliced into every action button site — the three toolbar variants (dialog / confirm / plain) and the three row variants — so the button carries class="wf-tb" data-roles="<role>". P40's filterNav then does the rest on load (and after /api/auth/me resolves): it reads window.MOSAIC_ROLE_LEVELS, fetches the caller's level, and removes any [data-roles] element whose minimum role bar exceeds it. No new client mechanism, route, or JS ships — P44 only adds the attribute and the plan check.

Consequences

  • An action can now be role-gated client-side: an under-privileged caller never sees the button (matching the nav-item gating P40 already provided for nav entries and palette items).
  • Reuses the existing P40 filterNav + the app's role ladder — no new runtime, no new route, no new client JS. The change is one attribute on the button + one plan-time check.
  • Advisory, not authoritative. The data-roles attribute only hides the button; a caller can always dispatch the command directly via REST. The command's own server-side auth is the real gate, and a requires that is stricter than the command's own auth is redundant (the server would reject it anyway) while one that is looser just shows the button to people the server will then reject. requires is a UX affordance, not a security boundary.
  • View caveat. Action buttons render only on table-view list pages; the cards and kanban lenses render no action buttons (rows_view_card takes no actions). So in the full-stack example — whose only action-bearing projection, RuleSetList, is a kanban view — the requires is exercised at plan time (role validation in the conformance build) while the actual data-roles button render is pinned by the render test on a table-view projection. This is the same verification posture as P36's action buttons, which no example renders in a golden (all action-bearing example projections are non-table).
  • data-roles is a data-* attribute on a Leptos element — the same class as P43's data-show-if / data-wfstep, which are proven to compile in the full-stack-web SSR build.

Verification

  • Render test (action_requires_renders_data_roles_on_the_button): a table-view projection whose row action and toolbar action each declare a requires role renders class="wf-tb" data-roles="editor" and class="wf-tb" data-roles="manager"; an action with no requires renders no data-roles.
  • Plan test (action_requires_unknown_role_is_a_plan_error): a requires naming a role not on the app's (standard) ladder is the fail-closed ui-action-requires-role diagnostic.
  • Example: full-stack declares requires on the RuleSetList actions (activate → editor, deactivate → manager); it plans green in the conformance build (the roles are validated end-to-end).
  • Gates: 477 workspace tests, clippy -D warnings, cargo fmt --check, and conformance (green — no golden regression, since the only action-bearing example projection is kanban and renders no action buttons, so the emitted pages.rs is byte-identical to the pre-P44 golden).