# ADR-001 — Single-Image Deploy for HTMX-SSR Consumers # Plain record (the framework has no ADR contract tooling yet); mirrors the # constellation _template.ncl shape so it can graduate to the typed schema later. { id = "adr-001", title = "Single-Image Deploy for HTMX-SSR Consumers — Baked Route Superset, Runtime Per-Site Substance", status = 'Accepted, date = "2026-06", context = m%" `website-htmx-rustelo/code` builds the shared `rustelo-htmx-server` binary that multiple sites consume (the personal bilingual site and ontoref/outreach/site). Historically the binary WAS one specific site: site/config (routes, menus, theme, logo, languages) is consumed at COMPILE TIME via build.rs/build-config codegen, so a new consumer had to edit the framework repo and rebuild to get their site. This is the framework-vs-one-site tension realised as friction: "we are incapable of creating an outreach/site using this framework as a base without modifying and recompiling it." Investigation (2026-06-14) showed the question is not which pole to pick but where to draw the seam: most per-site SUBSTANCE is already runtime (FTL deferred in htmx-ssr; SITE_BASE_URL, SITE_NAME, SUPPORTED_LANGUAGES via env; menus/footer loaded from routes.ncl/footer.ncl at startup; brand CSS via custom.css; templates via HTMX_TEMPLATE_PATH), while the route TABLE is compiled into the binary by the rustelo routing engine for type-safety and performance. "%, decision = m%" Deploy as a SINGLE shared image, not a per-site build. Bake the SHARED STRUCTURE (the route table, as a deliberate framework-level superset of all standard content kinds and pages) and supply every per-site SUBSTANCE knob at runtime: brand/logo (SITE_LOGO_LIGHT/DARK/ALT), name (SITE_NAME), public origin (SITE_BASE_URL), languages (SUPPORTED_LANGUAGES), content, templates, CSS, menus/footer (consumer routes.ncl/footer.ncl). A consumer runs the prebuilt binary, points env + supplies config/content, and gets their site without a rebuild. Routing stays compiled — it is NOT moved to runtime. "%, rationale = [ { claim = "Per-site build is adoption-hostile", detail = "Compiling per site forces every adopter to have the full Rust toolchain plus the stratumiops/foundation path deps and rebuild on each config change — contradicting the voluntary-adoption axiom and ADR-038 (single OCI image). A prebuilt image + consumer config is near-zero adoption cost." }, { claim = "Runtime routing would fight the framework's grain", detail = "The route table is generated into compiled code by the rustelo engine for type-safety and performance. Making it fully dynamic is a rearchitecture of rustelo core — the forbidden collapse to the all-dynamic pole." }, { claim = "Sites differ in content/brand, not route structure", detail = "Confirmed with the maintainer: future sites share the route shape (home/about/blog/projects/adr/catalog/...) and differ in content + brand. So one baked superset serves them all; unused routes simply carry no content and hide from the runtime menu." }, { claim = "The runtime-substance pattern already exists", detail = "FTL is already deferred to runtime in htmx-ssr; menus/footer already load from routes.ncl at startup; origin/name/languages are already env. Only the logo remained baked — now overridable via SITE_LOGO_* — so the model is mechanism-complete for substance." }, ], consequences = { positive = [ "One image serves N sites; adopters need only config + content, no toolchain.", "Brand/content changes never require a framework rebuild (only a shared-code bug or a new framework capability does).", "Aligns with ADR-038 (OCI single image) and protocol-not-runtime.", ], negative = [ "The baked route table must be maintained as a deliberate SUPERSET; a consumer menu/link to a route absent from the superset 404s (guarded by validate-route-superset).", "Per-site correctness depends on config discipline (the consumer must supply its own brand env + content); the seam must be documented (.claude/CLAUDE.md) and verified (validate-seam).", ], }, alternatives_considered = [ { option = "Per-site build / image", why_rejected = "Adoption-hostile (toolchain + path deps + rebuild per config change); contradicts voluntary-adoption and ADR-038." }, { option = "Fully runtime-dynamic routing (one binary, routes loaded at startup)", why_rejected = "Rearchitects the rustelo routing engine; loses compiled-route type-safety/perf; the forbidden all-dynamic collapse." }, ], constraints = [ { id = "routing-stays-baked", claim = "The route table is compiled; it is never moved to runtime loading.", scope = "crates/server (routing), site/config/routes.ncl", severity = 'Hard, check = { tag = 'Grep, pattern = "ROUTES_REGISTRY|resource_contributor", path = "crates/server/src/resources.rs" }, rationale = "Preserves rustelo's compiled-route type-safety and performance; the superset is the unit of structure-sharing." }, { id = "per-site-substance-is-runtime", claim = "Brand, logo, name, origin, languages, content, templates and menus are supplied at runtime by the consumer, never baked per site.", scope = "crates/server/src/theme.rs, shell, consumer run.sh + site/config", severity = 'Hard, check = { tag = 'NuCmd, cmd = "just validate-seam", expect_exit = 0 }, rationale = "A shared binary must carry each site's substance via runtime config; validate-seam rejects substance leaking into shared Rust." }, { id = "route-table-is-a-superset", claim = "site/config/routes.ncl declares ContentIndex + PostViewer for every standard content kind (blog, projects, adr, catalog) so any consumer's links resolve.", scope = "site/config/routes.ncl", severity = 'Hard, check = { tag = 'NuCmd, cmd = "just validate-route-superset", expect_exit = 0 }, rationale = "A consumer menu/link to a kind absent from the baked superset 404s — the build/runtime split gotcha." }, ], related_adrs = [ "ontoref adr-038 (OCI distribution — single runnable image)", "ontoref adr-029 (tier coexistence — optional accelerator, protocol-not-runtime)", ], ontology_check = { decision_string = "Single shared image: bake the route superset (structure), runtime the per-site substance — neither collapse to one site baked nor to fully-dynamic routing.", invariants_at_risk = ["framework-vs-one-site", "formalization-vs-adoption", "consume-not-fork"], verdict = 'Safe, }, }