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 + TUIweb— a Leptos (SSR / wasm-CSR) web frontend with live aggregate statesite— a static Zola API-reference site (commands / aggregates / workflows / projections)doc— authoreddoc→ zola pages + self-contained handbook (HTML) + presentation (one slide per chapter) + GitHub Pagesdeploy— docker / k8s / helm / aws / vps
Two gaps block treating web pages and documentation as first-class:
- No i18n anywhere. The only "language" is the DDD
domain.languageglossary (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 noAccept-Languagehandling. docis 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 DSL | YAML spec | |
|---|---|---|
| Files | *.tessera (text) | mosaic.yaml + tessera.yaml + deploy/*.yaml + src/*.rs |
| Behavior | declarative, in the DSL (commands emit events, aggregates hold state, workflows, …) | hand-written Rust in src/*.rs; YAML declares data + op routing |
| Kinds | full model (app, part, aggregate, doc, policy/IAM, vectordb, migrate, …) | Service / Proxy only |
| Renders | all five lenses (app + web + site + doc + deploy) | service / proxy + openapi + deploy |
| Conformance goldens | here (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/*.tomlevents/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.rolesreuses 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.pptxthat 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 (azola 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):
| Phase | Deliverable |
|---|---|
| P0 | locale decl + Localizable value type + parse + resolution (model only) |
| P1 | i18n in the static site: [languages], i18n/*.toml, per-language content, switcher, <html lang> |
| P2 | site decl + theme + page + section blocks + data-driven sections (backend html) |
| P3 | projects projections + context (audience/roles/locale/env/feature) + IAM reuse |
| P4 | presentation backends: reveal, pptx, google |
| P5 | dynamic /docs on the web lens (runtime role/locale/env/flags/live state) |
| P6 | backend: zola — emit full Zola project + build/deploy wiring |
| P7 | mosaic import <zola-site> / content migrate (port existing Zola sites) |
Progress:
- P0 (done):
localedecl +Localizablevalue type + parse + resolution; the YAML front-end loadsapp { name, about, title (scalar or per-locale map), locale }into the sameVec<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
pagedecls 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.rolesreuses the IAM model viaws.can_see(role, ctx)(role ladder +bypass+ explicit deny — the same data feeding the generatedcan()). The static projections render forapp.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_backendtoggle (YAMLapp.site_backend, text-DSLsite_backend:) —zola(default) emits the full Zola project plus build wiring (site/build.sh: installs a pinned zola release if absent, thenzola build);htmlrenders 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 underhtml(skipping Zola content) while the handbook/presentation are unchanged. Verified on the realfecg-bscontent:zola→ 63 files and a cleanzola 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/docssurface 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/docshandler resolves the caller's LIVE role (identity-stack-stampedx-authz-role, devx-role, else the app's default audience) and locale (?lang=, elseAccept-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 aviewersees only the open chapter, anadminsees the gated chapter too, and?lang=de/Accept-Language: deresolve the German titles. - P7 (done):
mosaic import zola <site>ports an existing Zola content site into atessera.yaml(app + locale set + localized pages). Readsconfig.toml(title per[languages.<code>],default_language) and the flat i18n content model (<name>.md= default,<name>.<lang>.md), and emits per-localetitle/prosemdasLocalizable. To make the import faithful,Section::Prose.mdis nowLocalizable(per-locale bodies). Verified end-to-end on the realfecg-bssite: import →mosaic build→zola buildrenders 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*.mdbodies land in a new localizableapp.homefield — and (b) flattens one level of nested content sections (content/stationen/*.md→ pages namedstationen-<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 fromsection.title/section.content— a section page has nopage.*context; per-localecontent/<lang>/index.mdfiles were wrong and are gone), and the default language's pages are unprefixed (content/pages/, →/pages/...) while other locales arecontent/<code>/pages/(→/<code>/pages/...), with locale-aware nav links (lang-switched hrefs). First production integrations landed:fecg-bsandbibelgarten-braunschweignow carry a committedtessera.yaml(5 and 11 pages, de/en/ru);mosaic build→zola buildrenders every page + the localized home bodies. - The YAML front-end now covers the entire decl surface — every
TopDeclkind: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(matchpointcut + advice blocks),command_groups,workflows(nodes/edges; node props as text/list/ fields/block),tests(suites/scenarios + app e2e),vector_dbs, andpersistence. All land as the sameTopDecls the text DSL produces, so plan + renderers are shared. Verified end-to-end: atessera.yamlexercising every kind plans and builds cleanly. - Examples to YAML (done): both reference examples are now authored as
tessera.yaml(the text.tesserafiles 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 atessera.yamland errors otherwise, and the legacyload_tessera_fileswalker is gone. The text parser (tessera::parse) is retained only as an internal test-fixture builder and for theparse_event_triggerhelper the planner uses. The conformance harness (mosaic-conformance) now pins two fixture kinds, both rendered byte-for-byte to<fixture>/golden/+ aMANIFESThash stamp: themosaic.yamlworkspaces (orders,proxy) and the tessera-model YAML fixtures (orders-app,full-stack, viatessera_yaml+tessera_plan::build+ the app/site/doc/web lenses).mosaic conformance --updateregenerates;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:
- Import —
mosaic import <source>converts the foreign artifact into a plaintessera.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: akind: proxytessera (the API surface becomes mosaic aggregates/ops). The output is markedGENERATED 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.
- Commit the model — the
tessera.yamllives 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. - Use mosaic features — everything a natively-authored model gets works
on an imported one:
mosaic build(app/site/doc/web lenses), thezolaandhtmlsite backends, i18n (the imported locale set drives per-locale content + nav + doc chapters),dochandbooks + 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)
- One model, YAML canonical — decided. YAML is the canonical DSL format;
the tessera-DSL model is the single model; the text
.tesserasyntax is deprecated (read-only during transition, then removed); service/proxy becomes akind. 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. - Defaults (accepted): site backend
html(zola opt-in); i18n authoring inline locale-maps and imported catalogs; presentation orderreveal+pptxfirst,google= pptx-that-imports; P3 context starts audience+locale+env (build-time), runtime added in P5.