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
Crate layout
Section titled “Crate layout”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 + validateThe boundary is its own crate
Section titled “The boundary is its own crate”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.
The one boundary that matters
Section titled “The one boundary that matters”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| dockerThe dotted edges are enforced by the dependency graph, not by review: the binary
crate does not depend on bollard at all.
One entry point, three surfaces
Section titled “One entry point, three surfaces”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.
The command reference is generated
Section titled “The command reference is generated”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 error-code reference is generated
Section titled “The error-code reference is generated”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.
Ports and adapters
Section titled “Ports and adapters”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.
Docker access, split by direction
Section titled “Docker access, split by direction”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).
The command builder is pure
Section titled “The command builder is pure”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.
Async model
Section titled “Async model”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 render loop never blocks
Section titled “The render loop never blocks”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.
Doctor: checks are values
Section titled “Doctor: checks are values”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).
Seed: one client per API shape
Section titled “Seed: one client per API shape”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).
Web UI
Section titled “Web UI”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).
Requirements
Section titled “Requirements”| 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. |
Related
Section titled “Related”- contracts/stack-manifest.md — what
lemonfiber-manifestparses - data-flow.md · platform-matrix.md
- ADR-0003 · ADR-0008
This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page