Mosaic one model, many lenses

ADR 0036 — Command validation + page actions + row states

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

Context

Two builder-parity gaps in the CQRS/UI surface:

  1. Commands cannot declare validation rules. A guard (command.where) is a single pre-dispatch condition over state/cmd that rejects the whole dispatch, and there is no way to (a) get a list of failed rules for a payload, or (b) check whether a payload would be accepted without dispatching — the "try before you commit" check every form-bound action needs.

  2. Projection list pages are read-only. The rows table renders live read-model rows with sort/filter/columns/charts, but a user cannot act on a row from the page: no per-row buttons, no page-level action buttons, no derived row status badges. Commands are reachable only via the generic command forms (admin pages) or authored REST endpoints.

Both are DSL-first: the model declares the rules and the actions; the lenses (app + web) derive the code.

Decision

1. command.validate (DSL + app codegen)

A command may declare validate: [<expr>, …] — tessera expressions over cmd.* and the aggregate's current state.*, each compiling to a boolean (the guard's expression scope/target). Semantics:

  • Dispatch path — the generated store wrapper (the public {agg}_{cmd} entry point, not _on) evaluates the rules in order before delegating to _on. A failed rule returns Err("validation failed: <rule>") (the rule's source text), which the endpoint maps to 400. Because it sits in the public wrapper, reactor/workflow/internal dispatches through _on skip validation — validation is an edge-of-system concern (the REST caller's contract), not an invariant check (that is the guard's job).
  • Validate-only path — a CQRS-delegate endpoint whose command carries rules reads the query string; ?validate=true (any value) evaluates the rules against the parsed payload without dispatching and answers 200 { "valid": bool, "errors": [ … ] } (failed rule source texts). The handler gains an axum Query extractor only when the delegate has rules (byte-identical otherwise).

The plan layer compiles each rule once (CmdPlan.validate: Vec<(source, compiled)>); the render layer emits the block, a read-only {fn}_validate(&self, cmd) -> Vec<String> store seam, and the short-circuit.

2. ui.actions + ui.row_states (DSL + plan + web codegen)

Projection ui: gains two keys (ADR 0008: YAML-only front-end — YamlUiAction / YamlUiDialogField / YamlUiRowState):

ui:
  row_states:            # badges per row, first match wins
    - when: 'row.active == true'
      label: "active"
      variant: success   # Badge variant (default info)
  actions:               # buttons bound to CQRS commands
    - label: "activate"
      command: "RuleSet.Activate"
      placement: row     # row | toolbar
      confirm: "activate?"        # two-step arm+execute (no dialog)
      dialog:                          # declared fields (optional)
        - field: note
          label: "note"
          widget: textarea            # text|textarea|number|checkbox|json
          required: true
      toast: "activated"              # success message (default "<label> ok")

Plan-time resolution (resolve_ui_page_features, after SSO validation) per action:

  • command: "Aggregate.Command" must resolve to a declared aggregate + command (fail-closed diagnostics otherwise).
  • Mount: the authored rest.endpoints item sourcing cqrs <Agg>.<Cmd> (POST) wins; else the auto-derived POST /api/{agg-kebab}/{cmd-kebab} (P27) — so actions work with zero endpoint authoring.
  • Row prefill: for placement: row, command inputs whose name matches a projection field are prefilled from the row at click time (never shown in the dialog).
  • Dialog fields: declared dialog[] fields (validated to name command inputs; Json-typed inputs become json widgets automatically) plus any input neither declared nor row-prefilled (auto-added, text widget). Toolbar actions have no row, so ALL inputs are dialog fields.
  • Row states: each when compiles with the expression compiler in the guard scope, target bool, over a row json-param (row.<field> → row.get("<field>") on the row's serde_json::Value) — so states work over any projection row without typed state.

Web codegen (the projection list page):

  • Toolbar actions render in the rows card header: a dialog action opens the page's dialog card; a confirm action is a two-step button (first click arms, second executes); neither is declared → a direct-execute button.
  • Row actions render in a trailing actions column; dialog actions carry the row into the dialog, confirm actions arm per row key (armed: Option<String> holds the key; the button label flips only for the armed row).
  • Dialogs: one #[component] per dialog action ({kebab}_{i}_dlg), rendered in a fixed modal overlay (.dlg-overlay / .dlg-panel), one field per dialog input (type-aware widgets), required-field checks before dispatch, cancel closes; submit POSTs the command body (row-prefilled fields
    • field values, JSON-encoded per type) to the mount, flashes the toast, and reloads (the fresh state renders the result; an error flashes inline). Each action's captures are owned per-closure (row_c{i}, rk{i} clones) because every move event closure in view! must own its captures.
  • Row states render as a status badge column: a {kebab}_row_state(row) -> Option<(String, BadgeVariant)> helper (one compiled when per state, first match wins) feeds a shadcn Badge with the declared variant.
  • A transient flash line (fixed bottom) carries toasts/errors; the api_post client helper is emitted whenever any page has actions (previously only for vectordb apps).

3. Generated-code constraints discovered

  • view! attribute values are parsed by syn, which (unlike rustc) rejects ;-separated match arms — generated match arms inside handlers are block-wrapped.
  • RwSignal (leptos 0.8) is the combined read/write signal: page action state uses RwSignal::new so the same signal can be .get()/.set() from handlers and passed as a signal prop to the dialog components.

Consequences

  • Forms can pre-check payloads (?validate=true → 200 + error list) and users get precise, per-rule feedback on dispatch (400 + failed rule text) — without duplicating guard logic.
  • List pages become operational surfaces: every declared command is reachable from the rows that its inputs describe, with auto-prefill, dialogs for the remainder, and confirm for destructive/no-field actions — no endpoint authoring required.
  • Guards and validation compose: a command can carry both (guard = internal invariant, validation = edge feedback); reactors are unaffected (they bypass validation by design).
  • Golden diff is small: the Query param appears only on rule-carrying delegates; the let out: Result<Value, String> annotation on cqrs handlers is explicit (needed for the short-circuit branch) and lands on all cqrs delegates uniformly.
  • E2E (full-stack example): Order.CancelOrder validates (state.status != "cancelled", len(cmd.reason) > 0); scenarios cover 200 dispatch, double-cancel 400, empty-reason 400, and validate-only true/false. RuleSetList gains row states (active/inactive badges) + row actions (activate/deactivate, two-step confirm over the row-prefilled id).