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-types crate is literally shared between the server (native) and the frontend (WASM) — the DTOs, the SSE event enum, and the AppError codes 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 to wasm32 both stay friction-free as long as no C creeps into the hot path. This single constraint drove several downstream picks — redb over 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, with leptos_axum for 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 via rust-embed), with the soft-real-time engine isolated on its own runtime in-process (ADR-004).

  • CLI / config idioms: clap for the CLI surface, figment for 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), one rust-toolchain.toml pin, one CI gate set. No JS build, no node_modules, no separate frontend deploy.

  • shared-types enforces 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 aarch64 cross-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, and wasm-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.toml pin buys the time to react.

  • serialport-rs bus-factor: the UART crate is publicly seeking maintainers. If it is abandoned, the ModemPort seam (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-types as 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 aarch64 cross-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.