Mosaic one model, many lenses

ADR 0037 — Command forms (command.form)

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

Context

Every command gets a command page in the Leptos web lens (/commands/<cmd-kebab>), but the page was read-only: a table of field names + Rust types and an "invoke" card naming the endpoint. To run a command a user had to leave the web UI (admin surface, curl, CLI) — the web's only command inputs were the P36 page-action dialogs (bound to a specific projection) and the workflow canvas's raw-JSON textarea.

Meanwhile the shared cmd-form dispatch handler (the static dispatch.js) already knew how to POST any form.cmd-form to its data-mount — it just treated every control value as a string, so typed inputs (ints, bools) could not round-trip.

The gap, DSL-first: a command should be able to declare its form (title, submit label, per-field hints) and the web lens should render a working, typed form for every command.

Decision

1. command.form (DSL + plan)

A command may declare

command:
  name: PlaceOrder
  fields: [ … ]
  form:
    title: "New order"      # default: the command's `about`, else its name
    submit: "Place order"   # default: "submit <Command>"
    fields:
      - field: customer     # a command input name (fail-closed)
        label: Customer     # default: the field name
        widget: text        # text | number | checkbox | textarea | select | json
        required: true      # default: the field's requiredness
        default: ""         # initial value (string literal)
        placeholder: who?

The plan resolves a CmdFormPlan for every command (declared or not):

  • Unlisted inputs are appended in declaration order with a type-derived widget: JSON/struct → json, enum → select (variants resolved from the aggregate's in-scope enums: its own + the schema enums), bool → checkbox, int/float → number, else → text.
  • Declared hints refine the auto row; an omitted widget derives from the type the same way.
  • Fail-closed at plan time: a field that is not a command input, a duplicated field, an unknown widget, select on a non-enum, number on a non-numeric, checkbox on a non-bool.

AggPlan.enums now carries the enums in scope for the aggregate's field types (its own plus the schema enums), so the P36 dialog resolution and the new form resolution agree on what an enum is.

2. The command page is a form (web codegen)

CommandPage_<Cmd> replaces the read-only fields table with a form.cmd-form card: one <div> per form field — a label (required fields carry *) and the control for the widget (<input type=text| number>, <input type=checkbox>, <select> with an empty "—" option

  • enum variants (the default variant selected), <textarea rows=3> (font-mono + {} placeholder for json)) — then the submit button (form submit label) and a dispatch-status line the handler writes to. The "invoke" card (endpoint + CLI hint) stays below. Non-command pages (workflow CLI commands) keep the fields table.

3. Typed dispatch (the shared dispatch.js)

The global cmd-form handler now encodes by control type: checkbox → bool, type=number → JSON number, textarea → JSON-parsed (falling back to the raw string), else → string. This also upgrades the P36 dialogs' SSR-sibling admin forms and any authored cmd-form.

4. P36 dialogs: the unified widget vocabulary

ui.actions[].dialog[].widget now accepts the same vocabulary (text|number|checkbox|textarea|select|json); an omitted widget derives from the field type (previously it had to be spelled out, and an empty value was an error). select renders an enum <select> (variants from the plan; string encoding on dispatch).

5. The web binary honors the app's CLI seam

The generated web main now reads serve --bind <addr> (the subcommand is ignored), then MOAIC_BIND_ADDR (ADR 0025), then the leptos dev site addr. The e2e harness spawns binaries with exactly that interface, so the web SSR surface is testable by the same app_e2e blocks as the app binary (the full-stack e2e gains a full_stack_web block asserting the rendered form's markup).

Consequences

  • Every command is runnable from the web with typed, pre-fillable controls — no JSON authoring for the common case; JSON fields keep a JSON textarea.
  • Form semantics are display-side: required marks the label, the server-side truth stays the guard + command.validate (P36) + deserialization; the form never weakens a check.
  • The command page SSR HTML is e2e-assertable (GET → 200 + markup), which becomes the pattern for the upcoming web slices (P38–P40).
  • AggPlan.enums widening changes the SSR admin form's selects: enum fields typed by a schema enum now render <select>s there too (previously text) — intended parity.

Verification

  • Plan tests: type-derived widgets (text/number/checkbox/select+ variants/json), declared hints (title/submit/label/placeholder/ required/default), unlisted append, and all five fail-closed errors.
  • Render tests: the command page's typed controls (incl. selected default variant, json textarea, required asterisk, dispatch-status), the dispatch.js number encoding, and the dialog select + derived widgets.
  • Full-stack e2e: a new full_stack_web block boots the web binary via the harness (serve --bind) and asserts the SSR form markup (declared title/submit, field controls, auto-derived json widget, declared default prefill).
  • Conformance re-pinned: command-page forms in all examples' web goldens, dispatch.js, the web main bind seam, and the full-stack app surface for the new PlaceOrder.urgent bool input (proto/agg/model/mcp/openapi/site).