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:
-
Commands cannot declare validation rules. A guard (
command.where) is a single pre-dispatch condition overstate/cmdthat 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. -
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 returnsErr("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_onskip 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 axumQueryextractor 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.endpointsitem sourcingcqrs <Agg>.<Cmd>(POST) wins; else the auto-derivedPOST /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 becomejsonwidgets 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
whencompiles with the expression compiler in the guard scope, targetbool, over arowjson-param (row.<field>→row.get("<field>")on the row'sserde_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
confirmaction is a two-step button (first click arms, second executes); neither is declared → a direct-execute button. - Row actions render in a trailing
actionscolumn; dialog actions carry the row into the dialog,confirmactions 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 everymoveevent closure inview!must own its captures.
- 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 states render as a
statusbadge column: a{kebab}_row_state(row) -> Option<(String, BadgeVariant)>helper (one compiledwhenper state, first match wins) feeds a shadcnBadgewith the declared variant. - A transient
flashline (fixed bottom) carries toasts/errors; theapi_postclient 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 usesRwSignal::newso 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
Queryparam appears only on rule-carrying delegates; thelet 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.CancelOrdervalidates (state.status != "cancelled",len(cmd.reason) > 0); scenarios cover 200 dispatch, double-cancel 400, empty-reason 400, and validate-only true/false.RuleSetListgains row states (active/inactive badges) + row actions (activate/deactivate, two-step confirm over the row-prefilledid).