Mosaic one model, many lenses

ADR 0027 — Surface auto-derivation: zero unexposed CQRS commands

Ports the core of EE's std.derive-cqrs-services + std.derive-bridges behavior into mosaic's planner: a CQRS command is a first-class surface. Once it exists in the aggregate, it is addressable as REST, MCP, and CLI unless the author explicitly opts out or has already claimed the surface.

Context

Mosaic's surfaces are projections over the tessera model. Before this ADR, a command was only reachable on the outside when an authored slot item pointed at it:

  • rest.endpoints with source: cqrs … for a REST route;
  • mcp.tools with a delegate for an MCP tool;
  • cli.commands with a delegate for a CLI verb.

That is the right model for custom mounts, custom methods, and curated tool names — but it made the common case verbose. A new command silently had no external surface until the author remembered to add the three bridge items. EE solved the same problem with derivation blocks: the CQRS service and its bridges are generated from the command declaration, and authored items are overrides, not prerequisites.

Decision

1. expose: bool on a command (default true):

aggregates:
  - name: Order
    commands:
      - name: PlaceOrder
        about: "Place an order"
        fields: …
      - name: InternalReconcile
        expose: false   # internal only — no derived surfaces
        fields: …

expose: false is EE's visibility: internal parity: the command still runs inside workflows/reactors/rule sets, but the planner derives no external surface for it. An authored slot item can still expose such a command explicitly.

2. The planner derives the three standard surfaces for every exposed command, after authored slot items have been collected:

surfacederived name / mountclaim rule
RESTPOST /api/{aggregate-kebab}/{command-kebab}skipped when an authored endpoint already owns that (method, mount)
MCPtool named {command-kebab}skipped when an authored tool already delegates to the same command; a different owner of the name is a warning
CLIcommand named {command-kebab}skipped when an authored CLI command already delegates to the same command; a different owner of the name is a warning

The derived items carry the command's about text and delegate to (aggregate, command), so they render through the exact same code paths as authored items (route registration, MCP schema, CLI clap command, CLI e2e test, OpenAPI, site command pages, TUI inventory).

3. Authorization rides the delegate. The derived REST endpoint is resolved with resolve_endpoint, so the command's own min_role, permission, and where (ABAC) conditions apply exactly as for an authored endpoint. There is no separate authz story for derived surfaces.

4. Collisions are visible, not silent. A cross-aggregate kebab collision (two Submit commands) warns surface-collision on the MCP/CLI name and keeps the first owner. The REST mount cannot collide (it is namespaced by the aggregate kebab). The warning tells the author to rename a command or set expose: false on one of them.

5. The MCP surface filter still wins. app.mcp.hide / app.mcp.expose apply after derivation: a derived tool can be hidden or left out of a whitelist like any authored tool.

Consequences

  • Zero unexposed commands is the default posture: declaring a command is enough to get POST /api/{agg}/{cmd} + an MCP tool + a CLI verb. The examples/full-stack and examples/data-sync goldens now contain derived routes/tools/commands with no authored bridge items.
  • Authored surfaces remain the override mechanism. A custom mount (/api/orders/place) or a renamed tool can claim the command's surface; the planner then does not duplicate it.
  • expose: false gives internal commands a clean opt-out without deleting their in-process dispatch.
  • The derived surfaces are deterministic (BTree-ordered aggregates/commands), so conformance pins them byte-for-byte.