Forms & partial stacks
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted · Audience: Operator · Area: B — Running it
Purpose
Run only the part of the stack you actually need right now.
Most self-hosted media stacks are a single compose file: sixteen services, all or nothing. Wanting to look up an NZB means booting a media server, a request portal, a subtitle daemon and four automation services. That isn’t primarily a resource problem — it’s that the tooling has no vocabulary for “I only need part of this.”
A form supplies that vocabulary. It is the primary noun the operator uses, and the mechanism (Compose profiles) is deliberately invisible to them.
Behaviour
Two layers: profiles are facts, forms are intent
Each service carries exactly one profile — a factual statement of what it is. Forms are named combinations, declared as data in the bundle, which also assigns each part its profile.
| Profile | Services | Count |
|---|---|---|
search |
prowlarr, flaresolverr, nzbhydra2 | 3 |
usenet |
sabnzbd | 1 |
torrent |
gluetun, qbittorrent | 2 |
tv |
sonarr | 1 |
movies |
radarr | 1 |
music |
lidarr | 1 |
books |
bindery | 1 |
subs |
bazarr | 1 |
media |
jellyfin, seerr, calibre-web-automated, audiobookshelf | 4 |
tuning |
recyclarr, unpackerr | 2 |
dash |
homepage | 1 |
proxy |
caddy | 1 |
Why acquisition and serving are split. Bindery acquires books and sits in
booksalongside the other *arrs; Calibre-Web-Automated and Audiobookshelf serve them and sit inmediaalongside Jellyfin. This keepslibrarymeaningful — you can serve an existing book collection without running an acquisition service you have no indexers for.
Forms
| Form | Profiles | Services | Intent |
|---|---|---|---|
search |
search | 3 | “Find me an NZB.” |
dl |
usenet, torrent | 3 | “I have a link — fetch it.” |
hunt |
search, usenet, torrent | 6 | Search and grab, manually. |
tv |
hunt + tv, subs | 8 | Automated television |
movies |
hunt + movies, subs | 8 | Automated film |
music |
hunt + music | 7 | Automated music |
books |
hunt + books | 7 | Automated books & audiobooks |
auto |
hunt + tv, movies, music, books, subs, tuning | 12 | Everything automated, nothing served |
library |
media | 4 | Serve what exists. Requires no third-party accounts. |
full |
all except proxy | 18 | The lot |
proxy |
proxy | 1 | Friendly hostnames — composable |
Forms compose
lemonfiber up full proxy starts the union of both closures. Composition is a
set union over profiles, so a service appearing in several active forms starts
exactly once.
This is what makes proxy viable as a form rather than a flag: a reverse proxy
is an orthogonal concern that layers onto any other form.
Closures are filtered by configured protocols
A form’s profile set is intersected with what the operator actually configured.
lemonfiber up dl on a Usenet-only setup starts SABnzbd and does not attempt
to start Gluetun with credentials that don’t exist.
Without this, every torrent-containing form would break for Usenet-only operators — which is a large fraction of them.
Forms are introspectable
The operator can ask what a form will do before running it: the profiles it expands to, the services that will start, which were filtered out and why, and the approximate memory footprint.
Among the services it would start, it names those already running. Starting a
form that is partly up leaves what is running as it is, so a running service
listed as one the start would bring up says something about the stack that is
not true — and says it right after a start, when every service in the form is
running and the introspection still lists each as one that would start. Where
what is running could not be read, the introspection says so rather than guess
either way (B1-R21).
The footprint is the stack’s estimate, not a measurement. Each service may declare the memory it expects to need; a form’s footprint is the sum over the services it would start, and the services that declare nothing are named beside it, so a sum that is short says so.
What is running names what brought it
Status names, beside each service, every active form whose closure holds it. All of them, because a service two forms share is there for both, and stopping one of them leaves it running for the other. A form counts as active while every service it holds has been started and none of them has been stopped: a service that failed is still part of the form somebody asked for, and its failure is its state rather than a reason to stop saying why it is there. A superseded form is not named, since its services are there because of the broader one.
Beside the services, status reports what the active forms’ closures filtered out: each service left out, the profile it belongs to, the provider it needed, and the forms that asked for it. A service that was filtered reads as the feature working rather than as one that did not start.
What the whole stack amounts to is worked out over what the active forms hold and whatever else is there. A stack running one form has every other service absent, and counting those against it would call it partial when it is doing exactly what it was asked. A service no active form holds counts only while it is there, so one that failed still reads as something to look at. With no form up there is nothing to count against, and every service counts. A service an active form filtered out is listed with what was filtered and not among the services, unless it is there.
Forms are data, not code
Adding or changing a form is an edit to the bundle in lemonfiber-plugins, or to the
stack manifest of a stack directory. It requires no change to lemonfiber’s source — see
ADR-0002.
On the companion
A form is the operator’s vocabulary for “I only need part of this”, and a screen small enough to show a count and not much else is where that vocabulary is most easily lost (N18).
N18-R2 forbids rendering a partial stack as degraded, incomplete, or as a
proportion of a whole. Six services of eighteen is not a stack that is
four-fifths broken; it is a stack doing exactly what somebody asked for. That
is a prohibition rather than a feature because every instinct a dashboard has —
show progress, show completeness, flag what is absent — arrives at the wrong
answer on its own, and it does so while looking like diligence.
Closures are filtered by configured protocols becomes N18-R3. Away from the
machine, did not start and was filtered out because you have no torrent
credentials are the same silence and mean opposite things: one is a fault to
chase and the other is this feature working. The reason travels, or the operator
goes looking for a problem that is not there.
Forms are introspectable becomes a rehearsal, labelled as one (N18-R4), and
the footprint stays the estimate this feature declares rather than a measurement
of what is running (N18-R5). Forms compose becomes N18-R6: a service
claimed by several active forms is shown once and names all of them, because
why is this running has as many answers as there are forms claiming it, and
stopping one does not necessarily stop it.
Forms are data, not code becomes N18-R7. An app holding its own list would
be a second copy that goes out of date silently, and the first thing the
operator would notice is a form of their own the app does not believe exists.
States
| State | Meaning |
|---|---|
available |
Declared in the manifest and startable with current configuration |
partially-available |
Declared, but some profiles are filtered out by protocol config |
unavailable |
Every profile filtered out, or a prerequisite is unconfigured |
active |
Currently running |
superseded |
Its services are running under a broader active form |
Edge cases
| Situation | Behaviour |
|---|---|
| Form named that doesn’t exist | List available forms and suggest the nearest match. Never a bare “not found”. |
| Every profile in a form filtered out | Refuse to start, naming the missing configuration and pointing at A4 reconfiguration. |
| Switching from a broad form to a narrow one | Stop only the services outside the new closure. Don’t tear down and rebuild what stays. |
| Two forms sharing services | Union. Each service starts once; stopping one form does not stop shared services still required by another. |
| Form declares a profile with no services | Valid but pointless — warn at manifest-validation time, not at runtime. |
| Operator wants an ad-hoc profile combination | Supported via composition. Direct profile selection is deliberately not exposed — profiles are an implementation detail. |
| A service in the closure fails to start | Report which, keep the rest running, and state what capability is degraded. Never roll back the whole form for one failure. |
| Manifest declares a form referencing an unknown profile | Fail manifest validation at load, with the offending form and profile named. |
library selected with no media present |
Valid. Start normally and note that libraries are empty until content is added or a path is configured. |
| Form composed with itself, or duplicates given | Deduplicate silently. |
Acceptance criteria
| ID | Requirement |
|---|---|
| B1-R1 | Each service MUST sit in exactly one profile: for a first-party plugin’s services, the one the bundle assigns that plugin; for any other plugin’s, its own; and for a stack manifest’s service, the one it declares. |
| B1-R2 | Forms MUST be declared as data, in the bundle or in the stack manifest of a stack directory; adding or altering one MUST NOT require a change to lemonfiber’s source. |
| B1-R3 | A form’s closure MUST be computed from the manifest, never hardcoded. |
| B1-R4 | Closures MUST be intersected with the operator’s configured protocols before starting anything. |
| B1-R5 | Multiple forms MUST be startable together, resolving to the union of their closures. |
| B1-R6 | A service present in several active forms MUST start exactly once. |
| B1-R7 | lemonfiber MUST report, before starting, which services a form will start and which were filtered out and why. |
| B1-R8 | Profiles MUST NOT be selectable directly by the operator. |
| B1-R9 | An unknown form name MUST produce a list of valid forms and a nearest-match suggestion. |
| B1-R10 | Narrowing the active form MUST stop only services outside the new closure, leaving shared services running. |
| B1-R11 | A single service failing to start MUST NOT stop or roll back the rest of the form. |
| B1-R12 | A form referencing an undeclared profile MUST fail validation of the bundle or the stack manifest declaring it, at load time. |
| B1-R13 | library MUST be startable with no third-party accounts configured. |
| B1-R14 | No service MAY declare a depends_on crossing a profile boundary, except within a profile whose services are always co-activated; the one exception is network.via (ARCH-R236), which starts the filler of the capability it names first, whatever its profile, and starts the service only once that filler is healthy. |
| B1-R15 | Which profiles require a configured provider MUST be declared by the bundle or the stack manifest declaring the profile; lemonfiber MUST NOT identify them by name. |
| B1-R16 | Status MUST name, for each service, every active form whose closure holds it, and MUST NOT name a superseded form. |
| B1-R17 | Status MUST report each service an active form’s closure filtered out, with the provider it needs and the forms that asked for it. |
| B1-R18 | A service MAY declare the memory it expects to need; a form’s introspection MUST report the sum over the services it would start as the stack’s estimate, MUST name the services that declare none, and MUST NOT present it as a measurement. |
| B1-R19 | Where a form is active, the condition status reports for the whole stack MUST be worked out over the services the active forms hold and those present, and MUST NOT count a service that is neither. |
| B1-R20 | Status MUST NOT list among its services a service an active form’s closure filtered out while that service is not present; it is reported as filtered (B1-R17). |
| B1-R21 | A form’s introspection MUST name, among the services it would start, those already running, and MUST NOT present one of them as a service that starting the form would bring up; where what is running could not be read, it MUST say so rather than present every service as one that would start. |
Related
- ADR-0002 Profiles and forms
- B2 Lifecycle control — starting and stopping what a form selects
- F2 Service catalogue — what each service does
- J2 Search only · J3 Download only