Mosaic one model, many lenses

ADR 0041 — CLI: schema dsl, diff, explain

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

Context

The tessera model is authored in tessera.yaml, a large typed surface (128 Yaml* structs). Three everyday tasks had no first-class tooling:

  1. Authoring aid — editors could not validate or autocomplete tessera.yaml: mosaic schema only covered the three legacy spec formats (tessera / mosaic / deploy via schemars), not the YAML surface that is now the only authoring format (ADR 0008).
  2. Change review — when a model changes (a field added to an aggregate, a projection renamed, a dashboard dropped), the only way to see what changed at model level was to diff the rendered output — noisy (generated code) and slow. A plan-level, semantic diff was missing.
  3. Error triage — mosaic check reports error[<code>]: …; the code is the stable identity of the diagnostic, but there was no way to look up what a code means and how to fix it without reading the plan-layer source.

Decision

1. mosaic schema dsl

schema gains a fourth what value, dsl: the JSON Schema for the tessera YAML surface. It is generated with schemars from the same Yaml* structs the loader deserializes, so the schema cannot drift from the parser:

  • every Yaml* struct derives JsonSchema (added alongside Debug, Deserialize);
  • fields typed serde_yaml::Value / serde_yaml::Mapping (free-form escape hatches) are schematized as serde_json::Value;
  • Localizable (an untagged string-or-map that also flattens) gets a hand-written impl: type: ["string", "object"];
  • YamlDeploy.proxy_buffering uses a small untagged YamlOnOff (string | boolean) because a strict YAML 1.1 parser reads bare off as false; the plan layer normalizes both forms to on | off (unchanged fail-closed validation).

The entry point is mosaic_core::tessera_yaml::dsl_json_schema().

2. mosaic diff <from> <to>

Two workspace roots (dirs containing tessera.yaml) are loaded and built to plan level, then compared. The comparison unit is a digest: Digest maps entity → facet → detail string, where the entity is <kind>:<name> (e.g. aggregate:Order, projection:Orders, workflow:CheckOut, dashboard:ops) and the facet is the meaningful attribute (state.<field>, command.<name>.fields, projection.<name>.on.<i>, …). tessera_diff::digest(&ws) covers the whole plan surface (app incl. ui/env/realms, aggregates, workflows, endpoints, cli, mcp, mcp_servers, dashboards, schedules, triggers, notifications, flags, rulesets, policies, admins, scenes, vectordbs, pages/docs, identity, peers, mirrors, aspects, gateway routes, schemas, tests, model registry, command groups, requirements, reactors, templates, queries, resources, domains).

diff(&old, &new) renders a deterministic report:

+ workflow:Refund.nodes.refund_call: …
~ aggregate:Order.state.priority: - -> Prim("String")
- schedule:nightly-report: <facets>

+ added entity / facet, - removed, ~ modified. Exit 0 when identical, 1 when different (like diff); parse or plan errors on either side abort with the diagnostics.

3. mosaic explain <code>

diag_docs carries a curated catalog — code → (what it means, how to fix it) — for every diagnostic code emitted by the plan layer (~124 codes: dashboard-unknown-projection, projection-unknown-event, cli-unknown-command-source, …). explain prints the entry, or says so when the code is unknown. A test scans the mosaic-core sources for every emitted code and asserts each is cataloged, so the catalog cannot silently lag the plan layer.

Consequences

  • tessera.yaml gets editor validation + autocomplete out of the box (point the YAML language server at mosaic schema dsl); the schema is derived from the deserializer, so it stays true by construction.
  • Model review is one command: snapshot the old model dir (e.g. from git or a backup) and mosaic diff old/ new/ shows exactly which entities and facets changed — without rendering.
  • Diagnostics become self-documenting: mosaic explain <code> next to mosaic check closes the "what does this error mean" loop without source diving.
  • All three are CLI-only: no render change (conformance stayed green, no re-pin), no new server surface, no DSL surface beyond the YamlOnOff normalization.
  • The diff digest is lossy by design: it compares declared model meaning, not rendered bytes. A change that only reorders generated code (never a real model change) shows nothing — which is the point.

Verification

  • schema dsl emits a valid draft-07 JSON Schema (~70 KB); all four example tessera.yaml files validate against it (Python jsonschema). The validation caught a real drift: proxy_buffering: off was typed Option<String> but is a YAML 1.1 boolean — now accepted in both forms.
  • diff on an unmodified workspace prints no differences (exit 0); adding one aggregate state field reports ~ aggregate:Order.state.priority (exit 1).
  • explain prints the curated entry for a known code and a clear fallback for an unknown one; the catalog-completeness test fails if any emitted code is uncataloged.
  • Gates: workspace tests (new core tests for diff/explain/YamlOnOff), clippy, fmt, conformance green (no re-pin), full-stack build + e2e.