← The specification

Component model

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

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

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-error/        lib — the error model: the problem a failure is
│                                  reported as, the registry of every code, and
│                                  the withholding a problem's detail passes
│                                  through; depends on no crate of the project
│
├── lemonfiber-fixtures/     lib — the fakes for those traits, reachable from
│                                  both in-crate tests and `tests/`
│
├── lemonfiber-testing/      lib — the context a test outside the core drives a
│                                  command through
│
└── lemonfiber-manifest/     lib — stack.toml parse + validate

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 error model sits below the ports rather than inside them. A port reports its failures as problems, and so does the core, so the problem type and the registry of every code it can carry are shared by both — and a crate of their own keeps the ports crate to the traits and the vocabulary that crosses them.

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]
    core --> error[lemonfiber-error]
    ports --> manifest
    ports --> error
    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.

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

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

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, because nothing holds it to the crates. So every code is declared once, in one registry in lemonfiber-error, beside the line that says what it means, and the code that raises it names it from there. The macro that declares a code also lists it, so there is no declaration a reader could fail to account for.

The registry holds only codes something raises: a test fails when a declared code is named nowhere in the code a release is built from. A code nothing raises leaves the registry, and its number is kept on the registry’s list of retired numbers, which no declaration may take again. No release can quietly recycle a number.

So the inventory is read from the registry — every code it declares — and emitted sorted into an artefact committed beside it, and a test compares the two. The page that says what each code means is held to that artefact in both directions, so it lists every code a release can raise and nothing else.

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

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

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

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

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

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

flowchart LR
    gather[Gather task] -->|one owned snapshot| loop[Render loop]
    keys[Keyboard thread] -->|keys| kch[(channel)]
    walk[Walk] -->|steps| wch[(channel)]
    act[Action task] -->|outcome| loop
    kch --> loop
    wch --> loop
    loop --> draw[Draw frame]

The render loop takes everything by value. A gather runs on a task of its own and hands back one owned snapshot when it finishes, and the next starts a tick later; keys arrive on a channel from a thread reading the keyboard; a walk’s steps arrive on a channel; an action runs on a task of its own and hands back its outcome. The loop holds no lock and shares no mutable state with any of them (ARCH-R16). The one thing it awaits in place is the stack’s declaration of its forms, read from disk, which ARCH-R15 forbids.

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

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

trait Client {
    async fn identity(&self) -> Result<Identity>;
    async fn register_download_client(&self, dc: &DownloadClient) -> Result<()>;
    async fn register_root_folder(&self, rf: &RootFolder) -> Result<()>;
    // … updating and testing a download client, root folders, quality profiles
}

Client is the Servarr shape’s port, and Servarr is the one implementation of it. Each client implements a narrower port for each other thing asked of its service — Transfers of Qbittorrent and Sabnzbd, Indexers and AppSync of Prowlarr, Requests of Seerr, Subtitles of Bazarr, Aggregators of Bindery, and more besides.

The manifest’s api.kind selects the client (contract). A service declaring api.kind = "servarr", an api.version, a port and media types is seeded from that declaration alone. What an import carries from a Servarr service, and which service a music format is applied to, are keyed by service id, which ARCH-R18 forbids.

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

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

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 The render loop MUST take what a background task produces by value — a gathered snapshot, a key, a walk’s step, an action’s outcome — and MUST share no mutable state with the task that produced it.
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 and lemonfiber-error.
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.