Mosaic one model, many lenses

ADR 0008: Pages, documentation, and i18n are projections of one DSL

  • Status: accepted
  • Date: 2026-09-26

Context

Mosaic already renders five "lenses" from one model:

  • app — Rust CQRS server + gRPC + CLI + TUI
  • web — a Leptos (SSR / wasm-CSR) web frontend with live aggregate state
  • site — a static Zola API-reference site (commands / aggregates / workflows / projections)
  • doc — authored doc → zola pages + self-contained handbook (HTML) + presentation (one slide per chapter) + GitHub Pages
  • deploy — docker / k8s / helm / aws / vps

Two gaps block treating web pages and documentation as first-class:

  1. No i18n anywhere. The only "language" is the DDD domain.language glossary (a term→definition map). Site chrome, doc content, and app strings are all single-language (<html lang="en">); there is no locale set, no per-locale content, and no Accept-Language handling.
  2. doc is chapters-in-a-docs-site. No custom layouts, no data-driven sections, one slide per whole chapter, and the generated site is a technical reference, not an authored/marketing page.

There is also a structural fork that must be stated plainly. Two input models exist today, each with its own render pipeline:

Tessera DSLYAML spec
Files*.tessera (text)mosaic.yaml + tessera.yaml + deploy/*.yaml + src/*.rs
Behaviordeclarative, in the DSL (commands emit events, aggregates hold state, workflows, …)hand-written Rust in src/*.rs; YAML declares data + op routing
Kindsfull model (app, part, aggregate, doc, policy/IAM, vectordb, migrate, …)Service / Proxy only
Rendersall five lenses (app + web + site + doc + deploy)service / proxy + openapi + deploy
Conformance goldenshere (tessera-model fixtures orders-app, full-stack + the spec goldens below)here (examples/orders, examples/proxy, incl. the AWS deploy goldens)

mosaic build picks the pipeline by file: a directory containing mosaic.yaml goes through the spec pipeline (ResolvedPlan → service/proxy); a directory containing tessera.yaml goes through the tessera-model pipeline (TesseraWorkspace → app/web/site/doc). The tessera model is the rich, fully-declarative model and the home of all recent feature work; the YAML spec is the thinner service/proxy model.

The goal: author web pages, documentation, and all user-facing strings once, in the tessera DSL, and let mosaic project them to every medium (static pages, handbooks, slide decks, localized variants, dynamic/runtime docs) — reusing the existing IAM model for audience-based access.

Decision

One source of truth, many projections, one i18n model.

0. YAML is the canonical DSL format

The model is written in YAML (a single tessera.yaml). The custom .tessera text syntax is removed from the product: no production path loads it (the CLI requires tessera.yaml), and every example + conformance fixture is authored in YAML. The text parser survives only as an internal test-fixture builder (parse_file) and for the parse_event_trigger helper the planner uses. Rationale: serde_yaml is already a workspace dependency; YAML is uniform, toolable, and content-shaped — which is where mosaic is heading (pages, doc projections, i18n, content collections). The in-memory model (TesseraWorkspace) is format-agnostic: the YAML front-end feeds the same AST the text DSL produced, so all feature work is shared — "convert to YAML" was a front-end swap, not a model redo. The thin YAML spec (service/proxy) becomes a kind in the same unified model.

1. The tessera DSL is the single source of truth

All new capability lands in the tessera-DSL model (TesseraWorkspace + the app/web/site/doc renderers). The YAML spec model is out of scope for these features.

2. Unified i18n (a value type, not a feature)

  • app { locale { default: "de" languages: ["de","en","ru"] } } declares the one locale set for the whole app.
  • Any string field becomes Localizable: a bare scalar (the default locale) or a per-locale map (title: { de: "…", en: "…" }). Long markdown and large UI sets use per-locale keys or imported catalogs (i18n { de: "i18n/de.toml" }).
  • One resolution drives everything: site chrome, doc content, page UI strings, and the generated app (a t(key, locale) helper + Accept-Language / ?lang= on API responses). The same model serves apps and docs — that is the "unified DSL."

3. First-class pages

A site top-level declaration = theme + nav + pages. A page is a layout + typed sections. Sections are authored or data-driven:

  • source: <Aggregate> binds a section to tessera data (the content collections, e.g. data/*.toml events/stations, become typed, validated aggregates that pages iterate).
  • page "/stationen/{slug}" { source: Station … } is a per-instance page.
  • A small set of section blocks (hero, cards, schedule, gallery, video, prose, cta, faq, table, embed) is "page layout in the DSL."

4. Projections (the unifying mechanism)

doc, page, and the auto reference site are all projections. A projection is source → target with a format and a context:

  • Targets: site (static pages), handbook (HTML manual), presentation (reveal / pptx / google / html), api (OpenAPI).
  • context: { audience, roles, locale, env, feature } filters the projection. roles reuses the IAM model: can(role, …) decides which commands, endpoints, and sections a given audience sees. This one mechanism covers static, dynamic, and context-based documentation.

5. Presentation backends

format: on the presentation projection:

  • html (default) — current self-contained deck.
  • reveal — a reveal.js deck: per-section slides, sub-slides from ##, speaker notes, themes.
  • pptx — a real PowerPoint file emitted at build time as Office Open XML (zip + XML; dependency-light, honors the free/own-base policy).
  • google — a .pptx that imports cleanly into Google Slides + an optional opt-in Drive-API upload step (manual, like the AWS deploy).

6. Site backend: render it, or orchestrate Zola

Per-site backend: (the DSL is identical either way):

  • html (default) — mosaic renders static HTML directly (as handbook / presentation already do). Zero external deps, deterministic.
  • zola — mosaic emits a full Zola project (config with [languages]/i18n, content/, templates/, static/) and the build wiring (a zola build
    • deploy step; generalizes the existing GitHub-Pages / CI templates). This is the "mosaic supports additional dependencies and drives them" path.

7. Dynamic + context-based app docs

The web (Leptos SSR) lens serves a /docs surface at runtime, where context is live: caller role (via the existing identity stack), locale (Accept-Language), env, current feature flags, and live projection state. Static (build-time, for the site) and dynamic (runtime, on the server) share one projection definition; only the context source differs.

Consequences

Roadmap (smallest useful increments, in order):

PhaseDeliverable
P0locale decl + Localizable value type + parse + resolution (model only)
P1i18n in the static site: [languages], i18n/*.toml, per-language content, switcher, <html lang>
P2site decl + theme + page + section blocks + data-driven sections (backend html)
P3projects projections + context (audience/roles/locale/env/feature) + IAM reuse
P4presentation backends: reveal, pptx, google
P5dynamic /docs on the web lens (runtime role/locale/env/flags/live state)
P6backend: zola — emit full Zola project + build/deploy wiring
P7mosaic import <zola-site> / content migrate (port existing Zola sites)

Progress:

  • P0 (done): locale decl + Localizable value type + parse + resolution; the YAML front-end loads app { name, about, title (scalar or per-locale map), locale } into the same Vec<TopDecl> and renders all lenses.
  • P1 (done): i18n in the static site — [languages], per-locale content, nav language switcher, <html lang>, localized doc chapters.
  • P2 (done): first-class page decls with typed sections (hero/prose/ html/cards/collection/image) rendered to Zola pages under /pages/<slug>/ + nav links.
  • P3 (context) (done): ProjectionContext { audience, roles, locale, env, feature } gates page sections, pages, doc chapters, and docs. roles reuses the IAM model via ws.can_see(role, ctx) (role ladder + bypass + explicit deny — the same data feeding the generated can()). The static projections render for app.audience (default: app.default_role); gated content below the audience is hidden from the site, handbook, and presentation. Authored in YAML (context: on pages/sections/docs/chapters, app.audience).
  • P4 (done): presentation backends via doc { presentation_format } (YAML: presentation_format:). html (default) = the self-contained deck; reveal = a reveal.js deck (title slide, one vertical group per chapter, ## sub-slides, <!-- … --> speaker notes); pptx / google = a real PowerPoint (Office Open XML) file emitted as a ZIP of XML parts via a dependency-light in-house ZIP writer (STORE + CRC-32). Binary outputs are carried in a parallel byte-map through the build/write path.
  • P6 (done): per-site site_backend toggle (YAML app.site_backend, text-DSL site_backend:) — zola (default) emits the full Zola project plus build wiring (site/build.sh: installs a pinned zola release if absent, then zola build); html renders the site as self-contained static HTML directly (no Zola, no build step) — per-locale home, first-class pages (title as <h1> + sections), the model collections (commands/aggregates/workflows/projections/reactors, list + detail), and the docs (TOC + chapters), all sharing one stylesheet + an embedded nav with the language switcher. The doc lens emits its "pages" projection as static HTML under html (skipping Zola content) while the handbook/presentation are unchanged. Verified on the real fecg-bs content: zola → 63 files and a clean zola build (18 pages); html → 19 self-contained files with correct localized titles/bodies (Contact / О нас) and no Zola artifacts.
  • P5 (done): the web (Leptos SSR) lens serves a dynamic /docs surface at runtime. The same doc projection (chapters + audience context) is emitted as data (web/src/docs.rs: per-locale titles + build-time-rendered HTML + a min role-level per chapter); the /docs handler resolves the caller's LIVE role (identity-stack-stamped x-authz-role, dev x-role, else the app's default audience) and locale (?lang=, else Accept-Language, else default) and filters chapters by the role ladder. Static (site) and dynamic (web) share the definition — only the context source differs. No docs → no module or route. Verified: generated web app compiles (cargo check); at runtime a viewer sees only the open chapter, an admin sees the gated chapter too, and ?lang=de / Accept-Language: de resolve the German titles.
  • P7 (done): mosaic import zola <site> ports an existing Zola content site into a tessera.yaml (app + locale set + localized pages). Reads config.toml (title per [languages.<code>], default_language) and the flat i18n content model (<name>.md = default, <name>.<lang>.md), and emits per-locale title/prose md as Localizable. To make the import faithful, Section::Prose.md is now Localizable (per-locale bodies). Verified end-to-end on the real fecg-bs site: import → mosaic build → zola build renders the de/en/ru pages with correct localized titles + bodies. Custom Zola templates/layouts are not captured (best-effort prose).
  • Import hardening + first production integrations (done): the importer now also (a) captures the home prose body — content/_index*.md bodies land in a new localizable app.home field — and (b) flattens one level of nested content sections (content/stationen/*.md → pages named stationen-<name>; a section's _index.md → the bare <dir> page), so no content is silently dropped. This drove two Zola 0.20 site-lens fixes: the home is the root section index (content/_index.md / _index.<lang>.md, rendered from section.title/section.content — a section page has no page.* context; per-locale content/<lang>/index.md files were wrong and are gone), and the default language's pages are unprefixed (content/pages/, → /pages/...) while other locales are content/<code>/pages/ (→ /<code>/pages/...), with locale-aware nav links (lang-switched hrefs). First production integrations landed: fecg-bs and bibelgarten-braunschweig now carry a committed tessera.yaml (5 and 11 pages, de/en/ru); mosaic build → zola build renders every page + the localized home bodies.
  • The YAML front-end now covers the entire decl surface — every TopDecl kind: app, pages, docs, migrations, parts, aggregates (entities/ops with expression-as-string fields), queries, resources, flags, schedules, notifies, templates, requirements, identity (jwt/users/oidc), domains (language/contexts/maps/services/events), models (role model), policies (roles/permissions/guards/attributes/boundaries/rules), schemas (structs/enums/aliases + example exprs), admins, reactors, rulesets (reactive actions + callable output), provisioning (event + claim shapes), aspects (match pointcut + advice blocks), command_groups, workflows (nodes/edges; node props as text/list/ fields/block), tests (suites/scenarios + app e2e), vector_dbs, and persistence. All land as the same TopDecls the text DSL produces, so plan + renderers are shared. Verified end-to-end: a tessera.yaml exercising every kind plans and builds cleanly.
  • Examples to YAML (done): both reference examples are now authored as tessera.yaml (the text .tessera files are removed; `mosaic build` prefers `tessera.yaml` when present). Porting them required a few extra YAML surfaces: app `parts`/`realms`, part `contributions` (dotted slots) + **nested decls** inside parts (a part can own its own `test`/ `domain`/`workflow`...), aggregate `projections` (upsert/update/delete/ increment on-events), and the text-DSL `enabled`-defaults-to-true for schedules/rulesets. Two normalization rules keep the YAML AST identical to the text one: scalar node values become `Text` (matching the text `scalar_text`), and reactor `trigger`/`invoke` raw text is re-lexed and space-joined exactly like the text tokenizer (`event Order.OrderCancelled` → `event Order . OrderCancelled`). Verified byte-for-byte: `mosaic build` from `tessera.yaml` and from the original `.tessera` produce identical file trees for `orders-app` and `full-stack` (73 files each), and the ported full-stack app passes its `app_e2e` scenarios (`/health` + `POST /api/orders/process`).
  • Transition completed + YAML conformance goldens (done): the text front-end is no longer loaded — the CLI (build/check/test/up) requires a tessera.yaml and errors otherwise, and the legacy load_tessera_files walker is gone. The text parser (tessera::parse) is retained only as an internal test-fixture builder and for the parse_event_trigger helper the planner uses. The conformance harness (mosaic-conformance) now pins two fixture kinds, both rendered byte-for-byte to <fixture>/golden/ + a MANIFEST hash stamp: the mosaic.yaml workspaces (orders, proxy) and the tessera-model YAML fixtures (orders-app, full-stack, via tessera_yaml + tessera_plan::build + the app/site/doc/web lenses). mosaic conformance --update regenerates; cargo test (and CI) fails if the renderers ever change the bytes for the same YAML input. The README's model example is now authored in YAML. All P-phases (P0–P7), the full model, and the examples are done.

Integrating other systems: mosaic import … then use mosaic features

mosaic import is the canonical on-ramp for bringing an external system into mosaic. The pattern is always the same three steps:

  1. Import — mosaic import <source> converts the foreign artifact into a plain tessera.yaml. Sources today:
    • mosaic import zola <site> — a Zola content site (config.toml + content/): app + locale set + home prose (app.home) + all content pages (flat i18n model; one level of nested sections flattened to <section>-<name> pages).
    • mosaic import openapi <spec> — an OpenAPI 3 document: a kind: proxy tessera (the API surface becomes mosaic aggregates/ops). The output is marked GENERATED by mosaic import … — review before building: import is best-effort (prose/markdown bodies are captured; custom templates, layouts and template-only data are not), so a human reviews the file before committing.
  2. Commit the model — the tessera.yaml lives at the root of the source repo, next to (or replacing) the foreign artifact. From this point the content is just a tessera model — there is no "imported" marker and no reduced capability.
  3. Use mosaic features — everything a natively-authored model gets works on an imported one: mosaic build (app/site/doc/web lenses), the zola and html site backends, i18n (the imported locale set drives per-locale content + nav + doc chapters), doc handbooks + presentations, audience contexts/IAM, flags, mosaic deploy, the conformance goldens, and of course editing the model by hand (adding aggregates, pages, docs…) to grow a static site into a full app.

First production integrations (2026-09-27): eugeis/fecg-bs and eugeis/bibelgarten-braunschweig (Zola church/content sites, de/en/ru). Both repos now carry a committed tessera.yaml; mosaic build → zola build renders every page with the localized titles, bodies and home prose, so the hand-rolled Zola projects can be retired in favour of the mosaic-generated site.

A site lens renders the chrome to fit the model: a content-only site (no aggregates/commands/workflows) gets no empty technical sections — the nav and the home list only collections that exist, and first-class pages appear in the nav/home under their localized title (branching on lang), never the bare page name. A technical app keeps its collection index.

A site can also carry its original design as a committed theme. app.theme: <name> points at a theme/<name>/ directory (a normal Zola theme: theme.toml, templates/, static/). mosaic build copies it to themes/<name>/, copies the repo's data/ (for load_data) and merges the repo's legacy config.toml into the generated one (the [translations] / [extra] tables the theme templates rely on; generated keys win, the legacy base_url is kept). The generated templates/base.html then only extends the theme's base.html and overrides the two model-driven chrome blocks — nav (collection + page links) and lang_switch (locale links) — while the theme keeps the head, header, footer, scripts and all styling. There is no generated site-level index.html: Zola falls back to the theme's own home template, which owns the whole home design and reads the imported home prose via section.content. Pages keep their original Zola template: (imported from the front matter, defaulting to page.html) and any [extra] front matter (icons, accent colors, media ids) so theme templates can read page.extra.*. page.in_nav: false keeps a page out of the main nav (curated navs, e.g. a station list that links from the home). This is how eugeis/fecg-bs and eugeis/bibelgarten-braunschweig keep their hand-rolled look (carousel, station cards, lightbox, dark mode) while the content, navigation and i18n become model-driven.

Deployment (Netcup git integration): the host has no toolchain and an old glibc (Debian 11 / glibc 2.31), so each site repo commits a static musl bin/mosaic (and bin/zola) and its deploy.sh runs bin/mosaic build . → bin/zola build → httpdocs/ as the Netcup post-sync action. The static-musl build needs the bin/musl-gcc-static linker wrapper: rustc's musl target links the dynamic-loader CRT (rcrt1.o), whose startup dereferences the weak _DYNAMIC symbol — 0 in a static binary — and segfaults at startup; the wrapper swaps in the static trampoline (crt1.o).

Decisions (format + model)

  1. One model, YAML canonical — decided. YAML is the canonical DSL format; the tessera-DSL model is the single model; the text .tessera syntax is deprecated (read-only during transition, then removed); service/proxy becomes a kind. Sequencing A: build the new features YAML-native on the active slice (app/locale/i18n/doc/site) now; migrate the rest of the model + examples to YAML as a follow-up; add a conformance harness for the YAML model so site/doc/i18n output is pinned by golden output.
  2. Defaults (accepted): site backend html (zola opt-in); i18n authoring inline locale-maps and imported catalogs; presentation order reveal + pptx first, google = pptx-that-imports; P3 context starts audience+locale+env (build-time), runtime added in P5.