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. |
Related
- contracts/stack-manifest.md — what
lemonfiber-manifestparses - data-flow.md · platform-matrix.md
- ADR-0003 · ADR-0008