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.endpointswithsource: cqrs …for a REST route;mcp.toolswith adelegatefor an MCP tool;cli.commandswith adelegatefor 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:
| surface | derived name / mount | claim rule |
|---|---|---|
| REST | POST /api/{aggregate-kebab}/{command-kebab} | skipped when an authored endpoint already owns that (method, mount) |
| MCP | tool named {command-kebab} | skipped when an authored tool already delegates to the same command; a different owner of the name is a warning |
| CLI | command 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. Theexamples/full-stackandexamples/data-syncgoldens 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: falsegives 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.