ADR-005: Rust full-stack (axum + Leptos, single binary)
Status: Accepted — (date: 2026-05-30)
Scope note: this ADR records the foundational stack choice — one language, end-to-end. ADR-001…004 (
architecture.md§ADR) are the closest-call sub-decisions that live inside this one: SSR/hydration (001), headless + own CSS (002), redb (003), monolith with runtime isolation (004). Read them as the consequences of choosing Rust everywhere.
Context
hamradio is a full-depth vertical stack on a single ARM SBC: RF + UART at the bottom, a soft-real-time async daemon in the middle, a live-data web UI at the top — built and maintained by one person, over multiple years, as a passion project with no budget. The stack choice was made collaboratively in coaching mode and is recorded in architecture.md §"Language, stack & build" (and reaffirmed in the Starter Template Evaluation).
Forces:
-
One language removes a whole seam. A Rust backend + a JS/TS frontend would reintroduce a language boundary, a second toolchain, and a hand-marshalled wire contract. With full Rust, the
shared-typescrate is literally shared between theserver(native) and thefrontend(WASM) — the DTOs, the SSE event enum, and theAppErrorcodes are one source of truth, type-checked on both ends. -
Pure-Rust dependency posture keeps the build clean. Cross-compiling the server to
aarch64(Story 0.3) and compiling the frontend towasm32both stay friction-free as long as no C creeps into the hot path. This single constraint drove several downstream picks —redbover SQLite (ADR-003),serde/argon2/minisign(all pure-Rust) — and is why "no C dependency" is a recurring rule, not a one-off. -
A boring, stable stack suits a solo multi-year project. Leptos is officially feature-complete / light-maintenance as of May 2026 (stable 0.8.x, no churning 0.9/1.0). For a single maintainer, a non-churning mature framework is a feature, not a risk — pinned and treated as a fork-readiness watch-item rather than a moving target.
Alternatives weighed and rejected:
-
A Go (or other) backend with a JS frontend — loses the shared-types win, reintroduces the language boundary the seam-first design works to avoid.
-
A hybrid / microservice split from day one — premature. "Build the seam, not the framework" (NFR-8): draw the boundaries, ship a monolith, split only on evidence.
Decision
Full Rust, end-to-end:
-
Backend:
axum+tokio, withleptos_axumfor SSR/hydration integration. -
Frontend:
Leptos(0.8.x line) compiled to WASM; headless components + own CSS. -
Topology v1: a monolith — a single self-contained binary (the cargo-leptos
site/bundle folded in viarust-embed), with the soft-real-time engine isolated on its own runtime in-process (ADR-004). -
CLI / config idioms:
clapfor the CLI surface,figmentfor layered config.
The choice is explicitly framed "for now" — revisitable within Rust if a later phase surfaces a real reason (architecture.md §"Language, stack & build").
Consequences
-
One toolchain (
cargo+cargo-leptos), onerust-toolchain.tomlpin, one CI gate set. No JS build, no node_modules, no separate frontend deploy. -
shared-typesenforces wire-contract coherence at compile time across the native/WASM boundary — casing drift, error-code drift, and event-shape drift become compile errors rather than runtime surprises. -
The pure-Rust posture is now load-bearing: introducing a C-linked dependency can break the
aarch64cross-compile and/or the WASM build. New dependencies are vetted for this. -
WASM bundle size becomes a real budget (≤ ~400 KB transferred) — owned via headless components,
opt-level=z, andwasm-opt(ADR-002, performance budgets). -
The single-binary shape is what makes the in-place update story a single-artifact swap (the updater depends on it).
Reversal-Trigger
The full-Rust language choice is stable; what is revisitable are the structural bets that ride on it. Concrete signals that would reopen part of this decision:
-
Engine/web process split (ADR-004): if measured load shows the in-process engine runtime isolation is insufficient to hold the ~30 ms voice-superframe cadence (Bench Spike 0(a)), escalate to
SCHED_FIFO/priority or a true process split over UDS. Still Rust — but no longer a single process. -
Leptos freeze trip-wire (pre-mortem M6): if Leptos won’t build on stable Rust within a bounded window after a breaking toolchain change, evaluate migrating the reactive layer to Dioxus. The CSS + ARIA spine is already framework-portable; only the reactive glue is exposed. The
rust-toolchain.tomlpin buys the time to react. -
serialport-rsbus-factor: the UART crate is publicly seeking maintainers. If it is abandoned, theModemPortseam (frame altitude) already isolates the blast radius — fork or swap the driver behind the trait without touching the engine.
If none of these fire, there is no reason to revisit the language choice.
Why-this-matters-to-a-future-maintainer
The three pillars here are single-language, single-binary, and pure-Rust, and each has a failure mode a newcomer can walk into without realizing it:
-
Reach for a second language for "just one service" and you’ve broken
shared-typesas the single wire contract — the thing that keeps the frontend and backend honest. The seam-first design exists so that you don’t need to. -
Add a C-linked crate (an easy SQLite or a convenience codec) and you may silently break the
aarch64cross-compile or the WASM build — the failure shows up far from the change. "No C in the hot path" is deliberate; the pure-Rust alternatives were chosen on purpose (ADR-003). -
Split into microservices before there’s measured evidence of runtime starvation and you’ve spent the project’s scarcest resource (a solo maintainer’s time) building a framework instead of the product (NFR-8). The split trigger is documented (ADR-004) precisely so the split happens on evidence, not on instinct.
See architecture.md §"Core Architectural Decisions" and ADR-001…004 for the sub-decisions this choice sets up.