Skip to content

Component model

Status: Accepted

Inside the lemonfiber box: what the components are, and which boundaries are load-bearing.

Satisfies: G1-R2, F1-R6, ADR-0003, ADR-0008


crates/
├── lemonfiber/ bin — chooses a surface and runs it
│ ├── cli/ clap definitions, non-interactive paths
│ ├── tui/ ratatui: dashboard, wizard, logs, doctor views
│ └── main.rs surface selection
├── lemonfiber-api/ lib — axum: the JSON endpoints, and the frontend served
│ ├── guard.rs the token, and what a request must say to be answered
│ └── serve.rs routes, and the bytes core hands over
├── lemonfiber-core/ lib — all logic, no UI, no terminal
│ ├── app/ the one entry point: command in, outcome out
│ ├── model/ the values surfaces render, and serialise
│ ├── adapters/ the only code that talks to Docker, HTTP or processes
│ ├── stack/ compose command construction, lifecycle
│ ├── frontend/ the built web assets, embedded as the stack is
│ ├── docker/ container state, stats, logs, exec
│ ├── config/ .env, paths, credential storage
│ ├── platform/ OS detection and per-platform behaviour
│ ├── doctor/ checks, findings, remediation
│ ├── seed/ service API clients, wiring
│ └── journal/ change log for rollback
├── lemonfiber-ports/ lib — the traits the outside world is reached
│ through, and the vocabulary that crosses
│ them; re-exported by the core as `ports`
├── lemonfiber-fixtures/ lib — the fakes for those traits, reachable from
│ both in-crate tests and `tests/`
└── lemonfiber-manifest/ lib — stack.toml parse + validate

The ports are not a module of lemonfiber-core. They are a crate below it, which lemonfiber-core re-exports as ports so call sites are unchanged.

Two reasons, which turn out to be one. The architecture holds that the boundary is the stable part and the logic above it is not; a crate makes that something the build enforces rather than a sentence in this document, because a port cannot reach up into logic that is not among its dependencies.

And the fakes implement these traits. A crate’s in-source test modules and its integration-test directory are separate compilation units, so a fake defined in either is invisible to the other — which is how one port came to be faked twice and the filesystem four times. A single fixtures crate resolves that, and it can only exist if the traits live somewhere lemonfiber-core is not: were the fixtures to depend on the core, the resulting development-dependency cycle would build the core twice and hand the fake a trait belonging to neither copy under test. That failure is silent — it compiles, and the fake simply never matches. ARCH-R44 and ARCH-R45 exist to keep it impossible rather than merely known.

Which types may cross is decided mechanically, not by taste: a type belongs to the boundary only if all of its behaviour can move with it. A type whose methods must stay behind is logic wearing a vocabulary’s clothes, and it stays in the core.

lemonfiber-core has no UI dependency of any kind. No ratatui, no clap, no terminal, no HTTP server. It cannot print. The surfaces depend on it and it depends on none of them, which is what makes the next sentence hold however many surfaces there come to be.

This makes G1-R2“surfaces are renderings, never capabilities” — structural rather than aspirational. A surface cannot acquire behaviour of its own, because the behaviour lives somewhere that cannot render.

It also means nearly all logic is testable without a terminal, which is what makes the test pyramid viable at all.

flowchart TD
cli[cli] --> core[lemonfiber-core]
tui[tui] --> core
web[web] --> core
core --> ports[lemonfiber-ports]
core --> manifest[lemonfiber-manifest]
ports --> manifest
core --> docker[(Docker)]
core --> fs[(Filesystem)]
cli -.->|forbidden| docker
tui -.->|forbidden| docker

The dotted edges are enforced by the dependency graph, not by review: the binary crate does not depend on bollard at all.

ARCH-R11 stops a surface from containing behaviour. It does not, on its own, stop three surfaces from reaching the same behaviour by three different routes — and three routes drift, which is how a flag appears in the CLI that the TUI never grows and the web UI implements slightly differently.

So there is exactly one way in. A surface turns input into a command, hands it to app, and renders what comes back:

async fn dispatch(cmd: Command, ctx: &Ctx) -> Result<Outcome, Problem>

A keypress, a subcommand and an HTTP route all become the same Command. This is what makes REPO-R10’s “every TUI action has a non-interactive equivalent” hold by construction rather than by review, and it is why ARCH-R20’s “the web API is the interface the TUI consumes” is a fact about types rather than a promise.

--dry-run is a property of the context, not a parallel code path, which is ARCH-R13 restated structurally: there is no second path to drift into.

model holds what Outcome is made of — the service states, findings, form plans and health summaries a surface renders. They serialise directly, so --json and the web API are the same values rather than two hand-maintained projections of them, and ARCH-R9’s api_version versions one thing.

A hand-written list of subcommands is a second description of the command line, and the two do not stay level. The list is edited when somebody remembers; the parser is edited when somebody ships. Between them the document goes on naming commands that were renamed, misses ones that were added, and promises flags that were never built — and nothing fails while it happens.

So the reference is emitted from the clap declarations themselves, the same ones the binary parses arguments with, into an artefact committed beside them; a test compares the two. This is the discipline ARCH-R66 already applies to the contract artefact, pointed at the other surface: a rename that forgets the reference fails the build rather than reaching a reader.

What stays written is what generation cannot say — what an exit code means, and what a command line must be able to do at all. Shapes are generated, obligations are written, and lemonfiber-reference holds the second half.

The same argument reaches past the command line. A code is the stable half of a problem — one token, never recycled, and the thing an operator searches for — so the page listing what each one means is where that search lands, and a page that claims to list every code is making a promise about the crates rather than about itself.

A hand-written list of them cannot keep that promise. Codes are declared beside the code that raises them rather than in a central table, which is deliberate: a code and its meaning move together, and no release can quietly recycle a number. It also means there is nothing to read them off. So a code added beside what raises it costs nothing and breaks nothing, and the list goes on saying it is complete while it is not.

So the inventory is read from the declarations themselves — every code the crates declare outside their tests — and emitted sorted into an artefact committed beside them; a test compares the two, and the generator and the test read through the same eyes so neither can be right about a list the other is wrong about. A declaration the reader cannot account for fails the build rather than being dropped from the list silently, which is the only failure that would make the artefact lie in the direction that matters.

What stays written is what generation cannot say: what a code means, and what to do about it. Generation fixes the boundary of that document; the words inside it are a person’s.

Everything outside the process — the Docker daemon, service HTTP APIs, spawned processes, the clock — is reached through a trait in ports, implemented once in adapters.

The split is what makes the test pyramid in testing-strategy achievable rather than aspirational: adapters is the only code that cannot run in a unit test, and it is deliberately thin, holding translation and no decisions. Everything that can be wrong sits on the other side of a trait and runs with a fake.

It also makes ARCH-R14 mechanically checkable rather than a rule someone has to remember: once each external dependency has exactly one legitimate home, a test can say so. How that test is written is lemonfiber’s own concern.

ADR-0008 splits reads from writes. The module boundary enforces it:

Module Mechanism Operations
stack::compose docker compose subprocess up down restart pull stop config
docker::* Docker Engine API (bollard) list · inspect · stats · logs · exec

stack::compose may spawn processes and never touches bollard. docker::* uses bollard and never spawns. Correlation between them uses Compose’s own labels (com.docker.compose.project / .service), which the Engine API exposes.

The exec path is what makes the VPN leak test possible — running a command inside both containers and comparing results (C2-R1).

stack::compose builds an argument vector; it does not execute. Execution is a separate, thin layer.

fn build(form: &Form, cfg: &Config, platform: Platform) -> Vec<String>

A pure function over manifest and configuration, which is why golden-file tests can cover every form on every platform without Docker present — and why --dry-run (F1-R2) is the same code path rather than a parallel one that can drift.

tokio, but deliberately shallow. Async exists where there is genuine concurrency:

Async Sync
Docker API streams (stats, logs) Manifest parsing
Concurrent service API calls during seeding Compose command construction
Concurrent diagnostic checks Config read/write
The TUI event loop Platform detection

Making pure computation async buys nothing and complicates testing.

The single most important runtime rule (B3-R4):

flowchart LR
poll[Docker poller<br/>~1 Hz] -->|snapshot| ch[(channel)]
logs[Log streams] -->|lines| ch
input[Terminal events] --> loop
ch --> loop[Render loop]
loop --> draw[Draw frame]

Background tasks own their data and send owned snapshots through a channel. The render loop never awaits Docker, never holds a lock across a draw, and never shares mutable state with a poller.

This avoids the failure that makes most TUIs feel broken — input freezing while something slow happens — and sidesteps the borrow-checker friction that shared mutable state would otherwise create.

trait Check {
fn id(&self) -> CheckId;
fn category(&self) -> Category;
fn is_disruptive(&self) -> bool;
async fn run(&self, ctx: &Ctx) -> Finding;
}

Every check is independent (C1-R4), bounded by a timeout (C1-R7), and returns a Finding carrying severity and remedy (C1-R2).

Finding makes unverified a distinct variant rather than a flavour of failure (C1-R3) — the type system enforces the distinction the specification insists on, so “could not check” cannot accidentally render as “passed”.

An error inside a check surfaces as a check error, never as a finding about the stack (C1-R8).

trait ServiceClient {
async fn identity(&self) -> Result<Identity>;
async fn register_download_client(&self, dc: &DownloadClient) -> Result<()>;
async fn register_root_folder(&self, rf: &RootFolder) -> Result<()>;
}
Implementation Serves
ServarrClient Sonarr, Radarr, Lidarr, Prowlarr
SabnzbdClient SABnzbd
QbittorrentClient qBittorrent
SeerrClient Seerr
BinderyClient Bindery

The manifest’s api.kind selects the implementation (contract), so adding a service that reuses an existing shape needs no Rust at all.

Every write goes through the journal (E4-R1) and consults drift state before overwriting (C9-R3).

An embedded static frontend plus a JSON API, both served from the binary. No separate process, no runtime toolchain, no npm at install time — the frontend is built in lemonfiber-web’s own CI, tagged there, and carried here as a pinned submodule that include_dir! embeds.

The API is the same one the TUI consumes, so parity is structural. Binding and authentication follow C6; the server runs only when asked (G1-R5).

ID Requirement
ARCH-R11 lemonfiber-core MUST NOT depend on any UI, terminal or HTTP-server crate.
ARCH-R12 Compose command construction MUST be a pure function, separate from execution.
ARCH-R13 --dry-run MUST use the same construction path as execution.
ARCH-R14 Compose invocation and Docker API access MUST live in separate modules with no cross-dependency.
ARCH-R15 The render loop MUST NOT await I/O or hold a lock across a frame.
ARCH-R16 Background tasks MUST send owned snapshots rather than share mutable state.
ARCH-R17 unverified MUST be a distinct variant in the finding type, not a severity value.
ARCH-R18 Service clients MUST be selected by manifest api.kind, never by hardcoded service name.
ARCH-R19 Web assets MUST be embedded in the binary; no runtime toolchain MAY be required.
ARCH-R20 The web API MUST be the same interface the TUI consumes.
ARCH-R42 Every surface MUST reach behaviour through a single dispatch entry point in lemonfiber-core; a surface MUST NOT orchestrate the core’s subsystems directly.
ARCH-R44 The ports MUST live in a crate below lemonfiber-core, depending on no other crate of the project except lemonfiber-manifest.
ARCH-R45 Test fakes for the ports MUST have a single home reachable from both in-source and integration tests, and that home MUST NOT depend on lemonfiber-core.
ARCH-R68 The command reference MUST be generated from the types the binary parses and MUST NOT be written by hand; CI MUST fail when the committed artefact and those types disagree.
ARCH-R69 The error-code reference MUST be generated from the codes the crates declare and MUST NOT be written by hand; CI MUST fail when the committed artefact and those declarations disagree, or when a declaration cannot be enumerated.

This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page