Skip to content

Implementation status

What is built in this repo versus what the spec roadmap still asks for. Read this before reconstructing state from the source — it exists so a new contributor (human or agent) does not have to.

This tracks the lemonfiber binary’s milestones, M2–M10. A milestone is not one version: M4 spans 0.3.0 and 0.4.0, M5 spans three, M6 spans 0.8.0 and 1.0.0 with seven versions in between, and M10 is scoped by no version manifest at all. M0–M1 live in the spec and lemonfiber-media-stack repos and are recorded here only for context. Milestone M8 is not started — its section is a stub pointing at the spec roadmap. Every other milestone has a table, and several hold rows that landed ahead of the rest of their milestone because something already built needed them.

  • Update this file in the same PR as the work it describes. A tracker in a separate change drifts; one that moves with the code cannot.
  • Status is per deliverable, mirroring the roadmap’s own tables. The landing column cites the PR or commit that made it true, so a claim here is checkable.
  • Never name an unfinished requirement inside a ✅ row, not even to say it is deferred. The release gate reads done-ness by finding an ID in a ticked row, so a sentence explaining that something is not done marks it done. Give the unfinished ones a row of their own.
  • Never write the tick character in a row that is not ticked. gate.py and status_lint.py both decide a row by whether its line holds the glyph, so a ◐ or ☐ row whose prose says “inside a ✅ row” is read as done — which is how three unmet requirements came to be counted as met. Write “a ticked row” instead. The word is safe; the character is not.

Legend: ✅ done · ◐ partial · ☐ not started


In the spec repo. Recorded here for context only.

CI, templates, and the citation-gated workflow are in force (DCO, CODEOWNERS, spec-references bot, labeler, SonarCloud gate, OpenSSF hardening).

M1 — lemonfiber-media-stack standalone · ◐

Section titled “M1 — lemonfiber-media-stack standalone · ◐”

The stack ships embedded as a submodule under assets/ and is read at build time; the manifest and compose fragments live there. The stack’s own standalone CI lives in the lemonfiber-media-stack repo, where every form is resolved by docker compose config on each change.

Two of the milestone’s three exit criteria are the two no CI can reach: a hardlink import verified end to end, and a killswitch verified by hand. Nothing in either repo starts a container, and that repo’s own README says so — so this is recorded partial rather than done, and the milestone closes when somebody runs it on hardware.


M2 — Core: manifest, compose driver, CLI · ✅

Section titled “M2 — Core: manifest, compose driver, CLI · ✅”

0.1.0, released. The parts everything else is built on: the manifest and its compile-time schema check, the compose driver, and the CLI they are reached through.

Deliverable Status Landing
Workspace + cargo-dist scaffold f8ee9d0
stack.toml parser + validation (compile-time schema check) #18, lemonfiber-manifest
Embedded assets (include_dir! + --stack-dir) #13
Platform detection (macOS / Linux / Linux-Desktop / WSL2) platform.rs
Compose command builder (pure, golden-tested) #14, stack/compose.rs
Form closure + composition (B1-R4, B1-R5) #14, #15, stack/closure.rs
up / down / restart / ps / logs / pull #17, #21
.env read/write (comment- and order-preserving) #16, config/env.rs
config get/set/show (with secret redaction) #19

The product-thesis milestone, built partly ahead of order: the diagnostics harness and the first check landed before the wizard. Shipped as 0.2.0. The free-space projection was completed later, once the download-client read the dashboard work introduced made the queue reachable.

Recorded partial, not done. This heading read as done while the table below it held a deliverable marked not-started, which is the one thing “done” cannot mean. Two of the fourteen C1 requirements the diagnostics suite claims are not met — running a single check, and saying how long a disruptive one disturbs the stack — and they have a row of their own. A milestone is done when every row under it is.

Six of what 0.2.0 locked carry no citation. A1-R1, A1-R7, A1-R8, A1-R9, A1-R10 and A2-R12 are claimed here by the ranges below, and no commit in this repository names any of them in a Spec: trailer — so 0.2.0 would not pass its own release gate if that gate were run against it today.

This is not the gate having arrived late: it landed four days before 0.2.0 was finalised. The citations were simply never written, and they cannot be added now without rewriting a released version’s history.

What that costs is exactly what the rule exists to buy. The rows below say the work is done, and for five of the six the code bears that out — but there is no trail from a requirement to the commit that met it, so the claim rests on the row alone and nobody reading this can check it.

The sixth is why that matters. Going looking for A1-R9 — warn and require confirmation where torrents are chosen without a VPN — found the warning and found nothing that asked. Setup had no VPN question at all, so the condition could never fire, while the prerequisites checklist told the operator that lemonfiber “warns and asks you to confirm before allowing it”. A tick nobody could audit was covering a promise nobody kept. It is implemented now (Step::Vpn), and cited — but it went four versions unnoticed because the row said it was done.

Deliverable Spec Status Landing / notes
doctor — Check trait, remedy per finding C1-R1, C1-R2, C1-R3, C1-R4, C1-R7, C1-R8, C1-R9, C1-R11, C1-R12 #22, doctor.rs. Every check establishes its finding by asking the running system rather than by reading configuration (C1-R1), and every non-passing result carries a remedy saying what to do about it (C1-R2). unverified is a verdict of its own, so a check that could not run never reads as one that passed (C1-R3); checks are values in a list, run at once and bounded individually, so one failing or hanging has no bearing on the rest (C1-R4, C1-R7 — an expired budget becomes an unverified finding rather than a lost check). An error inside a check is reported as a check error rather than as a finding about the stack (C1-R8), the same run answers a person and a script through one envelope (C1-R9), and a check whose prerequisites are absent reports skipped with the reason rather than failing (C1-R11). A full non-disruptive run finishes well inside thirty seconds because the checks run concurrently and each is bounded (C1-R12). unverified and skipped are not interchangeable either: a check that applied and simply was not asked for reports unverified with the command that gets an answer, because reporting it as inapplicable let a run read as healthy on the strength of a search nobody made (C1-R3).
Findings that share a cause say so C1-R10 A stack whose VPN is down raises a finding for every service behind it, and a flat list of them reads as a dozen unrelated faults rather than as one. attributed runs after the checks and links each finding to the finding underneath it, so a report says which one explains the others. What relates them is the manifest’s own depends_on rather than a table of check ids in Rust — the same reasoning that keeps narrowing reading profile.protocol instead of recognising profile names, so a stack that rewires or renames its services keeps working and nothing in the core has to know what any service is. It runs after the run rather than inside a check, because a check is independent by construction and cannot see what any other found — a property worth keeping. Only a non-passing finding can explain another: a dependency that is working explains nothing, and sending an operator to look at a service behaving perfectly is worse than saying nothing. Findings about the machine rather than about something running on it — the environment, the filesystem — have no service to be downstream of and are left exactly as they were.
Filesystem checks wait as long as a disk may take C1-R14 The two checks that reach the operator’s own storage — the hardlink probe and the free-space headroom — declare a budget of their own rather than running on the one that suits a container command. What they wait on is a disk: a network share over a busy link, or an external drive that has spun down, can take tens of seconds to answer its first request, and abandoning it would report hardware that is merely slow as hardware that cannot be read — which sends an operator to diagnose a disk that is working. Thirty seconds and not more, because two promises meet at that number: a run’s wall clock is its slowest check rather than the sum of them, since they run concurrently, so thirty is exactly the largest a single check can ask for while a full non-disruptive run still finishes inside the thirty seconds C1-R12 asks of it. A test asserts both bounds, so a change to either has to be made knowing about the other.
Diagnostics — one check runnable by name, and how long a disruptive one lasts C1-R5, C1-R6 doctor/narrowing.rs, engine/diagnosis.rs. A run is narrowed three ways — the whole suite, one category, or one check — and a check is named by the very identifier its finding carries, so what a report says is what can be asked for again: lemonfiber doctor --only storage.space, and ?only=storage.space over the local surface (C1-R6). Nothing was invented for the way in. An identifier begins with the family the check belongs to, and that is what resolves a name to the checks worth running, so no table of check names is kept anywhere — the same reasoning that keeps attributed reading the manifest’s own depends_on rather than a list in Rust. One check in the list can report several findings, so the family runs and what it found is filtered to what was asked for, with the overall verdict summed from what was kept: a run narrowed to one check is graded on that check rather than on the family it arrived with. A finding sitting above or below the asked-for identifier is kept — a check abandoned at its budget reports once under its family, and a check that reports once per service reports beneath its own name, and dropping either would answer “no such check” about a check that had just answered. A name nothing reports is refused (DIAG-1) rather than answered with an empty report, which would read as a stack with nothing wrong with it; a name that is neither a family nor a check inside one is still refused at the edge, where no stack is needed to know it. C1-R5’s missing half is in too: both checks the operator opts into now state how long beside what. The killswitch drops the tunnel and stops transfers, and says the seconds the tunnel is actually away for rather than the whole budget of the check that drops it. The release search spends one real search per service against the daily cap the indexers hold the operator to, and says that is what it costs rather than claiming to disturb the stack — nothing on the operator’s own machine stops working for it, which is also why the indexer credential is proved live on every run and is not gated: one request against the same cap is what proving a credential empirically costs, and reading the key out of configuration would establish only that a key is written down. Each length is said from the bound that enforces it rather than written into the sentence, so what is promised and what is enforced cannot drift apart — and those two are the whole set, held there by an architecture test: a check gated on having been asked for must reach for the one place a length is put into words.
VPN leak test (exec gluetun + client, compare IPs) #22, doctor/vpn. The Spec column is empty deliberately: this row used to cite VPN-1..3, which are the check’s error codes and not requirement identifiers — the spec defines no feature VPN, so nothing could ever have checked them. Every C2 requirement this check carries is claimed by the rows below it.
VPN egress verification — the comparison, and what it rests on C2-R1, C2-R2, C2-R3, C2-R11, C2-R12, C2-R14, C2-R15 doctor/vpn/. The public address is read from inside each container’s own network namespace rather than from the host, which is the whole point: an address read here tells you about this machine, and the check is about the download client (C2-R1). A client whose egress differs from the tunnel’s is leaking at critical severity, naming the network mode it should have had (C2-R2, C2-R12). An address service that cannot be reached leaves the check unverified and never pass — losing the oracle is not evidence either way (C2-R3). The tunnel’s exit country is reported beside the address (C2-R11). Providers are matched by capability rather than by an enumerated list, so an unlisted one is not unsupported (C2-R15). And the number the whole comparison rests on is no longer one stranger’s word: two address services are asked and a disagreement is reported rather than resolved (C2-R14, #211) — picking a winner would be inventing the answer, and a source that is cached or misconfigured returns a plausible address while traffic leaves in the clear. Silence stays distinct from contradiction, so an echo going down does not report a conflict.
Queue health — the assessment C7-R1, C7-R2, C7-R3, C7-R5, C7-R6, C7-R7, C7-R12 queue/ (#210). Assessed across the download clients and the *arrs together (C7-R1), because the failure that matters most is invisible inside either: an item that downloaded successfully and was never imported is a completed download to the client and nothing at all to the *arr, and neither will ever mention it (C7-R2). Stalls are categorised and each category carries its own advice (C7-R3) — a dead torrent wants re-grabbing, a permission wants fixing, a redownload loop wants stopping before it spends another hundred gigabytes while looking like ordinary activity; a test asserts no two categories give the same first remedy, since two that did would be one with a spelling difference. Checked worst-first, because an item fetched four times is also an incomplete download and reporting it as “not moving” sends the operator to the torrent instead of the import failing underneath. slow is its own category and explicitly does not want attention (C7-R6). Seeding at 100% is never reported as stuck (C7-R7) — the first draft did exactly that, flagging every healthy torrent, and a test caught it. Thresholds are time-based, adjustable and deliberately far out (C7-R5), and an item the operator set aside is never raised again (C7-R12).
Queue health — watching it across the services C7-R9, C7-R11, C7-R13, C7-R14, C7-R15 app/queue.rs (#213, #214). The port returns the service’s own total and the items on the page read — both, because counting the depth from a page silently reports two hundred for a queue of five hundred, while a number cannot say which items or why. An item carries what the service said went wrong, verbatim (C7-R9), which is the difference between “stuck” and something an operator can fix; a blank one becomes nothing rather than a line that reads as though the service had explained itself. (Carried by the port from here, but dropped by assemble until the history slice below — so no fault named a cause until then.) A service that could not be asked is named rather than read as an empty queue (C7-R11, C7-R15) — silence is not an empty pipeline, and reporting it as one is how an operator comes to believe a stack is idle when it is unreachable. The result is counted by category for a queue too long to list (C7-R13), and every stuck item raises a condition carrying its remedies, so it reaches the operator through the alert path rather than waiting to be found — though nothing ran this check until the refresh loop did, which is the row below that says so. How long something has been wrong comes from the condition store, because neither service reports it and it cannot be inferred: time since an item was added would call a download added three days ago and stalled ten minutes ago “stalled for three days”. Keyed by the item and the category, since one condition per item hands a new fault the old one’s stamp — the first version reported a download slow for twenty-seven hours the instant it started moving. And an item that leaves the pipeline is swept and cleared, which is how a stall that resolved itself resolves in the record too (C7-R14); without it a resolved stall stands raised for ever.
Queue health — reading the service’s history C7-R4, C7-R10 servarr/queue.rs, queue/assess.rs, app/queue.rs. Both categories the model has carried since #210 and nothing could reach, because the queue alone cannot show either. A loop looks entirely normal in the queue — one record, downloading, nothing wrong — so the history is read alongside it (C7-R4). Counted per item rather than per release, because a loop commonly changes release: the service grabs one thing for an episode, fails, and grabs a different thing for the same episode, and counting by release name would see unrelated items and never find it. And counted only since the last successful import, which is what separates a loop from an upgrade — an episode grabbed again after it arrived is a better copy replacing a worse one, and counting the grabs before it would flag every upgraded episode on the machine. A history that could not be read leaves the count at one rather than failing the queue read: a count nobody could take is not a loop, and losing the queue because a second read failed would turn a missing count into a missing queue. C7-R10 needed the blocking cause to reach the model at all — the port had carried it verbatim since #213 and assemble dropped it, so no fault ever named it. It is carried now, and where more than one item reports the same cause it is that cause that is raised, once, with the count: a full disk stops every download on the machine, and twenty conditions about it are twenty alerts for one thing to fix. Only where more than one shares it — a single item blocked by something is that item’s problem, and naming the cause instead would lose which download to look at.
VPN port forwarding — the client, and keeping it aligned C2-R4, C2-R5, C2-R19 doctor/vpn/forwarding.rs, app/forwarding.rs (#216). A tunnel that drops and returns is commonly granted a different port; the client goes on listening on yesterday’s, and everything looks correct from inside while nobody outside can reach it — downloads still arrive, so only the seeding stops, which is noticed last. Four questions rather than one, because they fail separately (C2-R4): whether a port was granted, whether the client is on it, whether it is reachable, and whether it is still the port it was. Reachability is never claimed from inside — establishing it needs somebody beyond the tunnel to try, and inferring it from a grant plus a matching client would assert the very thing that fails when a provider quietly stops forwarding. Starting the stack moves the client onto the granted port and journals the change (C2-R5, C2-R19); a diagnosis reports the mismatch with the fix as a remedy instead, because looking is not asking for anything to change. The client’s port is read back after being set — one that accepted the write without applying it would otherwise be recorded as configured while staying unreachable. A client already on the port is left alone, since a write that changes nothing still restarts the listener.
VPN egress, re-checked while traffic moves C2-R9, C2-R10 app/dashboard.rs (#218). Every refresh reads the tunnel and records what it found through the same health::observed translation the panel uses, so a watch and a panel cannot reach different conclusions about one tunnel. The reading is needed for the VPN panel regardless, so it happens on every refresh rather than only while something is downloading — which covers C2-R9’s “while transfers are active” rather than narrowing to it. A proven leak is a critical fault carrying the gateway as its cause where the gateway is itself down, and app/notify.rs tells the operator in the same refresh that recorded it, so a leak found after startup is notified immediately (C2-R10). A tunnel nobody could read is recorded as unverified rather than reported as safe. Corrected: this row previously cited app/egress.rs, a second implementation that nothing called — it passed no services, so it could not attribute a leak to a downed gateway. It has been deleted rather than wired, since it would have double-recorded the same check.
Storage — the consequence, and the services configured to match C5-R3, C5-R4, C5-R12 storage.rs, servarr/importing.rs (#217). The data root is probed at selection time by creating a file, linking it and comparing inodes — a successful link is a fact where a filesystem type is a hint (C5-R12) — and what a location that cannot link costs is stated in concrete terms: imports take minutes, use twice the disk, and torrents cannot seed from the library copy (C5-R3, now guarded so it must name all three and must not lead with the property). And the *arrs are configured to match (C5-R4), which nothing did before: the consequence was stated honestly while the services went on being told to hardlink on a volume that cannot, the one arrangement that fails rather than merely costing more. The whole media-management document is read before the one field is written, because the service replaces what it is sent and writing the field alone would silently reset settings the operator chose.
The choices that are decisions, not faults C2-R13, C2-R17, C2-R20 doctor/acknowledged.rs, app/accepted.rs, app/seeding.rs. Some of what this tool reports is a decision with a cost rather than something broken, and saying it once is right where saying it every run teaches the operator to skim — after which they skim past the faults too. Running torrents with nothing containing them was not a warning at all before this: the check resolved no VPN pair and reported “does not apply”, which reads as though it looked and found nothing to look at, while what it found was torrent traffic leaving under the operator’s own address. It is now its own warning (vpn.unprotected, and its own check rather than the tunnel’s, so an answer to “there is no tunnel” can never quieten “the tunnel is unhealthy”), and it can be answered — lemonfiber doctor --accept vpn.unprotected — after which it is marked suppressed rather than removed (C2-R13). Suppressed, never passed: “you chose this” and “this is not happening” are different claims and only one is true, so the finding still says what the cost is and a surface simply stops leading with it. A failure can never be answered, which is the single most damaging thing this could do — and the guard is on the verdict, not on a list of check names. Only something the run is currently warning about can be answered, because a name nothing warns about is a typo, and recording it silently would leave a question settled that is still being asked. What no forwarded port costs needs no record at all: it is stated at the two moments it is decided — setting the stack up that way (C2-R17) and changing it to be that way, the switch turned off or the provider swapped while it is off (C2-R20) — and the check itself stays skipped, so nothing repeats it. Not guessed from a provider’s name: whether a port actually arrives is the runtime check’s business, and inferring it would tell an operator their working stack is broken.
Storage — the single-mount rule C5-R5 stack/mounts.rs, enforced from stack.rs’s checked_manifest. Hardlinking is what makes an import cost nothing, and a link only works within one filesystem — inside a container a bind mount is a filesystem boundary. Two mounts beneath the data root put the download and the library on opposite sides of one, and anything imported across one is copied rather than hardlinked: twice the disk, minutes instead of milliseconds, and seeding stops when the original is cleaned up. No probe can catch it. The hardlink test (C5-R1) runs on the host, where the data root is one volume and links work perfectly; the breakage exists only in the container’s view, which is why this is a rule about the compose files. Read across the whole stack rather than file by file, because extends carries volumes — a service declaring one mount and extending something that declares another ends up with two, and a file-at-a-time reading would call that stack clean. YAML anchors are merged the way Compose merges them for the same reason, and both volume syntaxes are read. A named volume is not a path beneath anything, and ${DATA_ROOT_BACKUP} is not the data root — refusing a stack doing nothing wrong is its own failure. Counted per service, since every service mounting the whole data root once is the required form. The shipped stack was already held to this by lemonfiber-media-stack’s own lint, which resolves the interpolation through docker compose config — a fidelity this cannot have, since it must read a stack with no daemon and no environment file. What was missing is the half that lint can never see: an operator’s own fork, which lemonfiber reads and nobody else validates. Both now hold, and a test ties the embedded stack to the rule here as well.
VPN killswitch, actually tested C2-R7, C2-R8 The one VPN check that does not infer. Every other one observes a healthy stack and reasons about it; doctor/vpn/killswitch.rs takes the tunnel away and asks the download client whether it can still reach the world. A stack whose killswitch works answers no; one whose killswitch is a comfortable assumption answers with the operator’s own address, and that is the moment worth finding out. Disruptive by nature and gated behind lemonfiber doctor --only vpn --disruptive, reporting unverified until it has actually been run (C2-R7). The device is discovered, not named — gluetun runs OpenVPN over tun0 and WireGuard over wg0, and what they share is carrying the default route, so that is what is read and dropped, keeping the check capability-based like resolve_pair above it. What it disturbs it puts back and verifies it put back before reporting anything (C2-R8) — including when the run gives up on it. A check is abandoned at its budget by dropping the future it runs in, and a dropped future runs no restore at all, which left the tunnel down whenever the probe outlasted the budget. So the tunnel is taken away only where the whole disturbance fits in what is left of the run, and inside it the probe and the restore are each bounded: the moment the budget expires is never a moment the tunnel is down. A run that opted in is allowed the ordinary budget and that disturbance on top, so a stack slow to answer loses the killswitch proof rather than its tunnel. a restoration it cannot confirm is its own second finding (vpn.tunnel-restored) rather than a footnote on the first — a stack that both leaked and was left without a tunnel has two things wrong with it. It refuses to prove anything it cannot: a client already off the internet would answer “blocked” whatever the killswitch does, so that is reported as proving nothing rather than as a pass. Until this landed the killswitch finding was permanently Unverified, which is why D3’s torrent gate reads the VPN findings rather than the category’s overall verdict.
Preflight / Environment check (Docker present vs unreachable, Compose ≥ min) A2-R9, C1-R13 doctor/environment.rs, and setup now runs it before the first questionsetup’s preflight (setup/boot.rs) dispatches the environment diagnostic (the same check, not a parallel one — C1-R13) and stops with its remedy where the engine is missing or its daemon is down, told apart in the same words (A2-R9), rather than after the operator has answered everything
Empirical hardlink test (create / stat / inode + link-count) C5-R1, C5-R13, A2-R8 doctor/storage.rs; FileSystem port + Disk adapter
Storage-mode detection (fs type, network mount, exFAT, WSL2) C5-R2, C5-R14 derived from the probe; type named via sysinfo
Free-space check on the data root C5-R6 storage.space now projects exhaustion from committed downloads rather than only warning on a floor. committed_bytes sums what each download client still has to write — qBittorrent’s amount_left and SABnzbd’s mbleft, both carried on the shared Transfers read as Download.remaining and read through the one read_transfers the dashboard’s transfers panel also uses — and space warns when the free space left once that queue has landed falls under the floor, so the alert arrives before the disk fills rather than after. A stack with no reachable download client subtracts nothing, so the same floor still guards the raw free space. The broader disk-space management feature — attribution by category, escalating thresholds, halting acquisitions — is D5, a later milestone.
Degraded-link detection (was linking, now not) C5-R11 storage.hardlinks reports a regression against a baseline recorded in storage-state.json
Permission distinction (operator vs service PUID/PGID) C5-R10 storage.permissions; native-Linux only, ownership vs PUID/PGID (mapped away on Docker Desktop, so skipped there)
Data-root availability supervisor (stop on loss, no auto-restart) C5-R7, C5-R8, C5-R9 lemonfiber watch; the Volume port detects a vanished or swapped mount by device id and stops the forms
Credential validation against live services A3-R1, A3-R2, A3-R3, A3-R4, A3-R10 The cited requirements are all in — the doctor check, the live-validation engine, and the wizard steps that prove each credential before keeping it. The doctor check landed: doctor/credentials.rs reads each Servarr-shape service’s generated key from its config through the FileSystem port and runs servarr.rs’s identity() over the HTTP transport (ports/http.rs + reqwest/rustls adapters/http.rs), reporting the observed name and version on success (A3-R3) and keeping proven, rejected (401/403), unreachable (no answer) and unusable (the service’s own words, verbatim) as distinct verdicts (A3-R4). An unwritten key is a service still starting, skipped and retried, not a fault (D1-R1). It participates in lemonfiber doctor under the credentials category (A3-R10); diagnose resolves the targets from the running stack’s Servarr services, reaching each on its loopback port and reading its key from where Compose mounts its config. http is now a capability on the context. The live-validation engine (crate::validate) is now in and drives the Credentials step: a Validator port with a Live implementation proves an indexer (a real Torznab/Newznab search — A3-R2), an existing service (a Servarr-shape API read back for its identity), and a Usenet provider (an AUTHINFO login over NNTP through the ports/nntp.rs port and a real TLS-wrapped adapters/nntp.rs adapter), each into the same four outcomes told apart by cause (A3-R3/A3-R4) with the observed capability on success and the input never echoed. The wizard’s Credentials and Provider steps gather and prove these as they are entered — nothing kept before a test has run (A3-R1), a failure put back to the operator to retry / keep unverified / skip, an unproven one recorded as such — and the indexer is re-proven by doctor (doctor/indexer.rs) so a key that rots since setup is a finding rather than an empty search (A3-R10).
A pasted key is taken as the key that was meant A3-R5 validate.rs’s pasted(), applied in setup/proving.rs to both the indexer and the provider entry before the credential is proven and before it is kept, so the string tested is the string stored. Trimming at only one of the two would be worse than trimming at neither: the key would prove itself and then not work. Whitespace inside a value is left where it is.
Credentials never reach an outcome A3-R6 Validation::withheld() is applied in validate.rs where the service’s words cross into the model — the one point every outcome passes — rather than at each place that later renders one. An indexer refusing a key quotes it back inside its own description; the guard drives that case and asserts the key is absent without reprinting it.
A wait that did not answer says how long it was A3-R7 The bound is the transport’s (adapters/http.rs, 10s connect and 30s request); what was missing was reporting it. validate/reading.rs renders the elapsed time at its own precision rather than through spoken::duration, which rounds anything under a minute up to one and would report every timeout identically.
The tunnel’s exit country is reported A3-R9 doctor/vpn/probe.rs’s exit_country reads /country-iso from the gateway, asked only where the gateway both answered and is known; doctor/vpn/leak.rs’s labelled renders it beside the address in what the VPN panel shows.
One outage, said once A3-R11 A name that cannot be resolved and a route that does not exist are facts about this machine, not about the credential. validate.rs carries one flag per validator, so the first failure states the outage and what follows says it is the same one — reported against each credential in turn it reads as several bad keys. The flag is cleared by anything that reaches a service, because a served surface holds one validator across many outages and latching it would leave the first outage of the process the only one ever explained.
A certificate that was not trusted is named A3-R12 Verification is never skipped anywhere in this workspace; there is no opt-out to reach for, and the message says so rather than implying a flag exists. A handshake failure previously arrived indistinguishable from a refused connection, sending the operator to check a hostname and port that were never wrong. validate/reading.rs now names the certificate and states that nothing was sent past it.
Proceeding with an unvalidated credential A3-R13 CredentialChoice::Proceed in setup/proving.rs keeps the credential with validated: false so a later diagnosis can point at it. Guarded by a_credential_kept_unverified_records_that_it_was_not_proven.
Every setup answer says what changing it costs A4-R2 reconfigure.rs catalogues every answer setup writes with what changing it affects and whether it is cheap or consequential, in one place so two surfaces cannot state different costs for the same setting. app/configuring.rs states it while the change is being weighed rather than after it lands — on the staged run, on the rehearsal and on the write alike, since the moment before a change happens is the moment the cost is worth reading. Held to the wizard by every_answer_setup_writes_is_catalogued_as_a_decision: a setting setup starts writing without a row fails the build, rather than quietly becoming a decision nobody can be told the price of. A setting setup never asked about says nothing, since a cost invented for a change nobody worked out teaches the operator to dismiss the ones that matter. The four questions with no configuration home — the VPN prompt, the household, notifications and autostart — are revised through their own commands.
A change is classified before it is made, and a consequential one is confirmed A4-R3 reconfigure.rs already knew which of the fifteen answers setup writes are cheap and which are consequential; nothing read it on the way to the file. lemonfiber config set and POST /api/config-set now do: app/configuring.rs asks the catalogue before it writes, and a consequential change — the data location, either protocol, the container user or group, the VPN provider, the Jellyfin mode — comes back staged with what changing it affects, having written nothing. --confirm on the command line and confirm on the action apply what was read. A cheap change is simply made: a confirmation invented for a setting whose cost is nothing is how an operator learns to dismiss the ones that mean something, which is the same reason a setting setup never asked about is treated as cheap rather than guessed at.
Nothing is written before the difference has been shown A4-R4 The write used to happen first and the consequence was reported after it, which is a receipt rather than a review. reconfigure/review.rs now models the proposal itself — the setting, what it holds, what it would hold, what that costs, and which of four things became of it: applied, staged, turned away, or nothing to do because it already says that. It is on ConfigReport for every surface: the command line renders KEY: old → new with the standing under it (render.rs), and a browser reads the same thing out of the envelope. A rehearsal is the same answer with nothing written, so --dry-run and the staged run agree rather than being two accounts of one change. Credentials are withheld on both sides of the difference, and the comparison that decides whether anything differs is made before the withholding — read after it, every password change would compare two redactions and read as a no-op. A setting already holding what was asked for is not rewritten at all, since a write of the same value moves the file’s own timestamp and afterwards reads as an edit somebody made outside lemonfiber.
A replacement credential is proven before the working one is discarded A4-R8 Six settings are a field of a credential this product can test: the indexer’s address and key, and the four halves of a Usenet login with the switch that decides whether it is reached over TLS. app/configuring/proving.rs assembles the credential as it would stand once the change lands — from the file with that one setting overridden, so a key is proven against the address it will actually be sent to — and validate.rs puts it to the live service while the credential in force is still the one on disk. A service that answered and refused is the one answer no confirmation gets past: a blanket yes is exactly what a bad paste would be waved through by, and the operator would be left with neither credential working. Nothing answering at all is a different thing and is confirmable, since an operator working offline may know the credential is right and a permanent refusal would make the setting unchangeable. A credential that authenticated and is merely rate-limited is stored, because it is the right credential. A credential still missing a half is not put to a service at all — an address given before its key would be refused on the half nobody has entered yet, which would make the first of two changes impossible to make.
A change that cannot be applied safely leaves the file alone and says why A4-R11 The gate is in app/configuring.rs: a proposal that does not clear everything in front of it is blocked, the settings file is not opened for writing at all, and the refusal carries the reason in the words of whatever produced it — the service’s own, or the file format’s — rather than a paraphrase. Reached by lemonfiber config set and POST /api/config-set, and asserted against the file on disk beside the answer in what_a_change_costs_before_it_is_made.rs, since a report is not evidence of its own truthfulness. Two things stop a change today: a replacement credential that could not be proven (see the row above), and a value the product cannot read as what the setting is for — a Usenet port that is not a port number cannot be dialled and cannot be corrected by the provider, so it never reaches the file. The refusals A4-R5 will add for a moved data location arrive through this same gate; until they do, moving it is stated and confirmed but not checked against the library paths.
Every setup answer is individually revisable A4-R1 Six of the ten answers setup makes are held in settings and revised with config set; the household and autostart choices have their own commands. The tenth — the notification appetite — had neither: setup wrote it, the digest read it, and nothing in between could change it. appetite::record existed but no command reached it, so it was exercised only by its own tests and read as finished while being unreachable. lemonfiber alerts now shows the preset in force and what it means, and lemonfiber alerts set takes another, leaving the individual exceptions in place — a broader answer is not a reason to discard the specific ones already given. A preset this build does not offer is refused by name rather than falling back to the quiet default, which would leave the operator believing they had changed something.
Moving the data location carries the library or refuses A4-R5 Each *arr holds absolute root-folder paths inside its container/data/media/tv — so a move leaves the paths spelled the same and pointing somewhere else, which is why the breakage is silent. app/reconfiguring/library.rs asks every *arr that answers for the folders it actually holds — rather than the ones lemonfiber would write, which an adopted stack’s would not match — and resolves each against the new location through the filesystem port; reconfigure/relocating.rs decides what each comes to. A folder whose host directory exists at the new location is carried and the proposal says where it lands; one whose does not, or one outside /data that no move can re-point, is blocked with the file exactly as it was. That refusal carries no --confirm — there is no version of the *arrs pointing at absent paths that the operator is better off with, and offering to force it teaches them the flag is a lie — so it says what to do instead. Where not one *arr will answer, silence is not read as an empty library: the move is refused where there is a media directory under the old location, and simply made where there is not, so a stack that never ran is not blocked by services that were never up. What each folder lands on is said while the change is still staged, which is the only moment it is worth having. Driven end to end in what_changing_one_answer_costs.rs against *arrs answering with real root folders.
Adding or dropping a way of downloading A4-R6, A4-R7, A4-R10 Turning LEMONFIBER_USENET or LEMONFIBER_TORRENT on reports exactly what that protocol newly asks for and nothing else (A4-R6): reconfigure/protocols.rs takes the difference of prerequisites.rs before and after for the accounts, and of the wizard’s own step gating for the settings their answers are kept in — so adding Usenet opens the provider login and never the tunnel, and adding the second protocol does not ask for the indexer again. That gating moved into Step::wanted_by and Step::settings in wizard/steps.rs and Wizard::applies now reads it, so the questions setup puts and the list a change states cannot disagree. Turning one off says what stops — the services of the profiles that protocol guards, read from the manifest — and, in full, what is kept: the downloads and the library under the data location, named with their paths, and each service’s own record of what it holds (A4-R7). The change writes one setting and reaches no eraser. A reduction with work still in flight names each download, its progress and which client has it, and is blocked until the operator chooses (A4-R10): --wait lets them finish through the same drained a down --wait uses, and --confirm goes ahead and loses them. What is in flight is narrowed to the protocol being dropped, so a Usenet download is not named when torrents are switched off, and a wait asked of a change that takes nothing away waits for nothing.
A hand-edit to configuration is never overwritten silently A4-R9 The environment file is one an operator edits — comments and ordering are preserved on every write for that reason — so a change made through lemonfiber may be about to write over one made outside it, and the file alone cannot tell them apart. reconfigure/edits.rs reaches for the three-way comparison seeding already makes (seed::reconcile) rather than writing a second one: what lemonfiber last wrote, what the file holds now, and what is about to be written. The record is kept in the same baseline.json seeding keeps, under a reserved service name a Compose id cannot collide with, written by app/apply.rs at the end of setup and by every change afterwards — merged into what is already there, so a seeded stack’s records survive, and never for a credential, since a second file holding a password is a second file to leak one. An edit found underneath is shown from both sides with the value withheld where a listing withholds it, nothing is written, and --confirm is what says whose value wins. A setting lemonfiber has no record of is adopted rather than judged: without a third value an operator’s edit cannot be told from what setup wrote, and calling it an edit would refuse the first change made on every machine.
A quality preset change affects future acquisitions only A4-R12 render/quality.rs states it on the preset and on the music choice, and on the rehearsed path as well as the applied one: “This affects future acquisitions only — nothing already downloaded changes.” The choice is recorded rather than applied, so nothing on disk is rewritten to match it and nothing already downloaded is touched.
VPN port-forward validation + ProtonVPN NAT-PMP guidance A3-R8, A3-R14, A3-R15, C2-R6, C2-R16, C2-R18 vpn.port-forward finding: reads the granted port from Gluetun’s status file; names ProtonVPN’s NAT-PMP-at-generation trap on failure, generic for other forwarding providers, unverified for a provider whose capability is unknown rather than assuming either way, and not-applicable where forwarding is off — never a failure for a provider that simply does not offer it.
Prerequisites / account guidance (dependency map before credentials) A1-R1..R13 prerequisites.rs derives the map, and setup now renders it: the moment protocols are chosen, setup::run shows the checklist those choices imply through the prompt — the accounts needed, each with what it is, why, its cost band, and the criteria (no vendors) that decide it (A1-R2/A1-R4/A1-R5), the Usenet provider told from the indexer (A1-R6), the VPN’s port-forwarding criterion and its consequence stated at the point of choosing (A1-R12/A1-R13), the torrents-without-a-VPN warning (A1-R9), and the library-only zero-account path stated plainly and first (A1-R3). Shown before the questions that follow, and resumable (A1-R1/A1-R8). The wizard’s step order was corrected to ask protocols before the checklist derives from them (spec a2 fixed first). A1-R7/A1-R10 (credential entry and validation) are in with the Credentials and Provider steps (see the Credentials row). A1-R9 is its own step (Step::Vpn), asked only where torrents were chosen and after the checklist has said what a VPN is for: a no states what every peer in a swarm can then see and asks a second time, and only a second yes goes on. Declining returns to the question — it never refuses, which is the half of the requirement that is easiest to break by making the warning a wall. The answer records which it was (Vpn::Absent rather than a bare false), so a later diagnosis reads a decision rather than an oversight. Until this landed the checklist told the operator lemonfiber “warns and asks you to confirm” and nothing asked.
Wizard state machine (resumable, review-before-write, non-interactive guard) A2-R1..R5, A2-R10..R15 wizard.rs: the read-only-phase state machine — ordered steps, applicability/skip (container-user asked only where ownership is real; native Jellyfin only where offered), forward/back navigation, resumable Progress (serde, restored to the reached step), answer validation that enforces the platform gates, the non-interactive guard (unanswered), and the offer-setup gate (A2-R1..R5, A2-R13, A2-R14; A2-R6/R7 via the platform predicates). Writes nothing itself (A2-R2). Wizard::plan renders the gathered answers into the exact environment settings apply will write (protocols, data root, container user, JELLYFIN_MODE) — the same value review shows (A2-R3); an unanswered question contributes no setting. The failed-apply recovery frame is now in ahead of the write it guards (A2-R10): Progress carries a lifecycle Phase (in-progress/reviewing/applying/applied, #[serde(default)] so older progress files still load), Status::of classifies what a later run finds — collapsing a persisted applying marker to failed-apply, since a live apply is its only writer — and Recovery reports exactly what the interrupted apply wrote (from the change journal) and resolves the operator’s resume / roll back / start over choice — both roll back and start over reverse the recorded writes via the journal’s rewind so nothing is stranded on disk, differing only in whether the answers survive. Apply’s decision layer is now in, still pure: Wizard::transition walks the lifecycle only along the edges setup takes — review only once every question is answered, apply after review, applied after apply, and the one backward edge a rolled-back apply takes to return to review — refusing every skip or quiet downgrade, so a writing or written phase is unreachable without passing the gate the earlier one stands for. Plan::changes turns the reviewed settings into the exact journal Set changes the write both makes and records, each reading the value the environment file held before (Some("") for a present-but-empty key, None for a new one) so an interrupted apply unwinds to precisely what was there. The config-write half of the I/O executor is now in: app/apply.rs’s apply drives a reviewed wizard through the lifecycle and lands its settings — persisting applying to setup-progress.json before the first write, journalling each change to journal.jsonl before it is written, writing each setting through the config store, and persisting applied last; a stop anywhere leaves the marker and journal the recovery frame reads (an unreviewed wizard is refused, SETUP-1, having written nothing). The shared text writer lives in config::store::write, so progress, journal and settings all land — and report a NotWritten — the same way. Apply now also creates the data directory before the settings: the operator’s chosen location is made where it does not already exist, journalled as a reversible Made first so a stop after removes it, and left untouched and unrecorded where it is already there (the operator’s own library to adopt), so unwinding never removes it; a location that cannot be created stops the apply with its own SETUP-2 and the marker left for recovery. Apply now also materialises the stack: the embedded compose files are written where Compose reads them (paths.stack()) through Source::materialise. The stack is lemonfiber’s own regenerable output — rewritten identically on every run — so unlike the settings and the data directory it is not journalled: its undo is simply the next apply rewriting it, a build artifact rather than stranded work, which keeps the journal’s Delete a single “remove exactly this empty directory” for the operator-owned paths. An external stack (--stack-dir) is the operator’s own, already on disk, left as it is. apply now takes the install Paths and the stack Source, and a Fault names each way a write can fail (a store file, a data directory, the stack) so one boxing site turns any into a problem. Step 12’s write set is complete. The setup orchestration that drives the wizard is now in too: app/setup.rs’s run walks the wizard and a Prompt port together — asking each question that applies here and is unanswered, in order (a resumed or non-applicable one passed over), then applying once the operator confirms the plan (A2-R3); an answer the platform rejects stops it (SETUP-5) and a declined review applies nothing. The asking is a port, so the whole walk is driven in a test by a scripted prompt with no terminal. It is now runnable end to end: lemonfiber setup (setup.rs’s setting_up + the terminal prompt adapter that reads and renders each question) drives the wizard on a machine with nothing configured, applies the answers, and brings the stack up — refreshing the settings it read at startup against the file it just wrote, then dispatching up on the tv form through the same settle-and-health path every start uses. It offers setup only where nothing is set up and points a configured machine at its settings (A2-R14), refuses a piped or scripted run that has no one to answer rather than blocking on stdin (A2-R13), and does not rehearse under --dry-run. It also routes recovery (A2-R10): before anything else it reads the saved progress and, on a failed-apply, shows the operator what the interrupted run wrote (loaded through the new setup::progress_at and recover::journal_at) and offers the three ways out the wizard keeps recoverable — resume (re-apply from the recorded answers via setup::resume, since apply persists them), roll back (recover::undo the writes, then apply again), or start over (undo and discard the progress and journal) — so a stopped setup is never mistaken by the configured-yet check for a finished one. Gathering now saves progress after each answer (A2-R4): quitting mid-question leaves a resumable file, and a later run reads it as in-progress and picks up where it left off — the wizard, restored, is asked only the questions it still lacks. Setup also runs the environment preflight before asking anything (A2-R9/C1-R13, see the Preflight row) and shows the prerequisites checklist derived from the protocol choice (A1, see that row). A bare lemonfiber is now the front door (A2-R1/A2-R14): main’s greet builds the context, and on a machine with no configuration offers first-time setup right there — Run first-time setup? [Y/n], defaulting to yes — rather than behind a subcommand a newcomer cannot know to type, deferring the offer to setting_up only once taken; an unfinished setup (a stopped apply or a quit mid-question) is caught before the configured-yet check and handed to setting_up so a half-written .env is never mistaken for a finished one; a configured machine is pointed at config set, up and --help instead of walked back to its first question (A2-R14); and a piped bare run states the offer rather than blocking on a stdin no one will answer. The offer gate is the same core offer_setup predicate the setup subcommand uses. The data-location step now tests hardlinks empirically (A2-R8): the create-link-inspect probe was lifted into a shared storage::test_link (one place, called by both the storage diagnostic and setup, per the filesystem port’s own contract), and setup’s gather — now async — runs it against the chosen location before recording it, testing the deepest parent that already exists since nothing is created before Review (A2-R2). A location that links is taken and the good result shown; one that cannot — or one that could not be tested (unwritable, or nothing on the path reachable yet) — is put back to the operator as a StorageWarning naming the filesystem’s own reason (exFAT, an SMB/NFS share, the WSL2 boundary — C5-R14) and its concrete consequence (the same storage::COPY_CONSEQUENCE a later diagnosis states), to use anyway or replace, looping until one is settled on; the storage mode is never asked, only derived (C5-R2). Credential entry and live validation are in (A3, see the Credentials row). Setup is now flag-driven for automation (A2-R13): lemonfiber setup takes --yes plus a flag per question (--protocols, --data-location, --indexer-url/--indexer-key, --library, --service-user, --household, --autostart), parsed into SetupFlags (a malformed value is named, not defaulted). A run with no terminal answers from those flags through a Flags Prompt — the same walk a person drives, so it still probes the data location and proves the indexer, with the warnings a person would weigh settled by the standing --yes. Where a flag a question needs is missing, the run is told exactly which (wizard.unanswered() mapped to flag names) rather than left waiting on stdin; an indexer and container user are optional (a supported empty answer), so they are never demanded. Per-image pull progress with an expected-duration statement (A2-R11) is in too: a streaming Runner::stream yields Progress events line by line, pull_progress composes them across a form’s images, and setup’s Start phase renders the expected-duration line before the wait (main.rs’s stream).
Jellyfin native-mode + PUID/PGID offers (platform-aware) A2-R6, A2-R7 Platform-gated decision logic and the wizard that presents both offers are in. Environment::ownership_is_real gates the PUID/PGID ask (A2-R6, native Linux only) and Environment::offers_native_jellyfin gates the native-Jellyfin offer (A2-R7, macOS/Windows only, per ADR-0007); the wizard rejects a native-Jellyfin answer where it is not offered (wizard.rs) and the terminal prompt renders each offer only where its predicate holds — the service-user step where ownership is real, the native-Jellyfin choice where the platform offers it.

Exit criteria: a fresh machine reaches a running tv form in under 15 minutes with no service web UI opened; the leak test provably catches a misconfigured VPN. Neither is verified. Nothing measures the fifteen minutes — no timing assertion touches setup, no workflow runs one, and the only place the figure appears in this repository is this sentence. And the leak and killswitch checks are proved against a scripted fake engine (tests/vpn.rs), which is the right way to test the reasoning and is not a misconfigured VPN. Both wait on somebody running them on hardware, the same way M1’s two do.


Spans 0.3.0 (backup & restore — the E3 rows below, complete) and 0.4.0 (auto-wiring & seed). Wiring services to each other and recording it so it can be undone. The lemonfiber seed command exists and wires the first edge — qBittorrent’s web UI password (D1-R16): it reads the temporary password from the container’s log, replaces it with a generated one through the client, and records the generated one in QBITTORRENT_PASSWORD where the forwarded-port push reads it. It also wires each media-filing *arr’s root folders — one per media type, under /data/media — reading the application’s key from its config and skipping an application that has not written one yet. It now also registers each *arr’s download clients: SABnzbd where its generated key is on disk, qBittorrent where its password was minted this run — or, on a later run when nothing is minted, where the recorded password is read back from QBITTORRENT_PASSWORD, so an *arr that came up after the first seed still learns about qBittorrent. It now also runs Prowlarr’s app sync in the other direction: each of those media-filing *arrs is registered back into Prowlarr as an application, so Prowlarr pushes it the shared indexers. And it makes Jellyfin the identity source for Seerr: Jellyfin has no key on disk, so lemonfiber mints its admin password by driving Jellyfin’s own first-run setup, records it, and signs Seerr in through Jellyfin — never re-pointing an already-initialised Seerr, whose existing sign-ins are the household’s.

Deliverable Spec Status Landing / notes
service::Client port (Servarr shape) D1-* ports/service.rs, SEED-1..3
Servarr-shape adapter (identity, register client/root folder) D1-R11 servarr.rs: one Client for all four Servarr apps, on the HTTP port. identity + register_download_client/register_root_folder post to the versioned API with the key; a transport failure is Unavailable, 401/403 is Unauthorised, other refusals carry the service’s verbatim message. Fake-Http-tested. root_folders and now download_clients read the service’s connections back — the latter decoding each client’s endpoint (host and port) out of Servarr’s fields array, so a connection can be matched by where it reaches rather than its label. The per-implementation download-client write schema is now filled in: register_download_client builds the registration document the download-client contract describes — the implementation/configContract and fields that differ between SABnzbd (Usenet, apiKey) and qBittorrent (torrent, username/password), with the category field named per target application. DownloadClient now carries the client kind, its credential and its category.
Seed orchestration (skip-if-absent, preserve operator edits) D1-R2..R6, D1-R8..R10, D1-R12..R14 seed.rs: the pure policy (intent/ReportD1-R2/R3/R5/R6) plus the first execute-driver, wire_root_folders: observes the service through the port (Client::root_folders, now read-back via servarr.rs), leaves a folder already present by path (D1-R2/D1-R8), skips every folder when the service is unavailable (D1-R5), and for each missing one registers it, reads it back before calling it wired (D1-R4) and journals the write. A root folder a second *arr also wants is refused rather than wired, since two *arrs on one folder would each rewrite the other’s files: contested_roots finds every path more than one *arr claims — up front, across all of them — and wire_root_folders returns a Refused naming the other *arr, made only once the service is reachable so one still starting is skipped and retried rather than handed a verdict a re-run cannot lift (D1-R10). A folder outside the mounted data root (/data) is refused too — the service would file where its downloads are neither hardlinked to nor visible to the rest of the stack; every folder lemonfiber builds sits within it, so the guard makes the refusal explicit and tested rather than only structural (D1-R9). A service that answers but does not serve the API version this build speaks — a 404 on the whole /api/v{n} prefix, a service upgraded past (or standing before) it — is detected by servarr.rs’s probe (a new Failure::Unsupported) and reported rather than written to: seeding refuses it (the same Refused, since a re-run against the same service cannot lift it) and the credentials doctor reports it unverified with the remedy to align the versions, so nothing malformed is ever posted (D1-R12). A connection made before an interruption survives it — written straight to the service, read back, and independent of the rest — so a re-run finds it already present and leaves it untouched while it completes the ones the interruption never reached (D1-R13); and a pass against a healthy stack stays well inside the minute setup owes, a bounded set of calls (one small group per service, no in-pass wait or retry — a not-ready service is skipped and left to the next run) each bounded by the transport’s connect/request timeouts, so against a healthy stack a pass’s time tracks the number of services and even a hung one costs only a bounded wait — a timeout per call it does not answer — before it is set aside (D1-R14). wire_download_clients now carries the same shape to download clients, with one difference that is the point of it: a client is matched by the endpoint it reaches — host and port — not by its label, so one the operator renamed is recognised as the same connection and not duplicated (D1-R8). wire_applications now carries the same shape to Prowlarr’s applications, matched by the baseUrl Prowlarr reaches each *arr on. The remaining graph edges follow the same shape.
Download-client credentials (read own key / generate) D1-R1, D1-R16 A download client is registered into a *arr with the client’s own credential. sabnzbd.rs’s api_key reads SABnzbd’s generated key from its sabnzbd.ini — matched by the exact api_key entry so a neighbouring nzb_key is not mistaken for it, empty/absent = not-generated-yet (D1-R1), the same text-read shape as the Servarr key reader. qBittorrent has nothing durable to read, so lemonfiber generates, sets and records its WebUI password (D1-R16). The generation primitive is built: secret.rs renders bytes from a Random port (OS CSPRNG adapter) as a .env-safe hex secret, None rather than a weak fallback where randomness is unavailable. Setting it is built too: qbittorrent.rs reads the temporary password qBittorrent logs on start, authenticates with it, sets the generated one, and confirms by authenticating again with it — a cookie-session flow, unlike Servarr, so the Web adapter now keeps a host-scoped cookie store. The seed driver that runs the exchange is built too: seed.rs’s wire_qbittorrent_password mints the password from the Random port, sets it through the client, and hands the value back for the surface to record — no randomness means no set and nothing recorded, never a weak fallback. The lemonfiber seed surface now runs the whole exchange and records the result, and on a later run reads the recorded password back from QBITTORRENT_PASSWORD (an unreadable file reads the same as an empty one) so qBittorrent is still offered as a download client once its temporary password is gone. The rest of the graph — Prowlarr app-sync and Jellyfin→Seerr — has since landed (see the wiring row); only Bindery is deferred.
Change journal (read-back + undo) E4-R1, E4-R2 journal.rs: each Change records the four things a reversal and a readable history need — timestamp, originating operation, target, and the before/after values (E4-R1); undo yields the inverse Action per change (remove the created resource by the id the service returned, restore a value / remove it where there was none, or delete a path that was made — E4-R2), and rewind unwinds most-recent-first. Three change kinds now: a service resource Created, a config value Set, and a filesystem path Made — the last the reversible vocabulary apply’s directory creation needs, undoing to a Delete of exactly the path lemonfiber created (never one that was already there). Pure data + serde for the jsonl log; the surface stamps the time and persists, and seed and apply write it. The undos it yields are now carried out by app/recover.rs’s undo: a Restore rewrites (or removes, via the new EnvFile::remove/store::unset) a setting, a Delete removes a made directory with a plain empty-only remove_dir (so an operator’s populated location is never emptied, and a directory a stop left unmade counts as already undone), and a service-made Remove — which only the service that made it can undo — does not stop the reversible work: those are set aside and reported together at the end (SETUP-4), so a mixed journal still has its settings and directories fully reversed. A real I/O failure (a setting or directory that will not budge) does stop it, most-recent-first, leaving a sane re-runnable partial state. This closes the failed-apply loop: the recovery frame detects it, decides the undos, this reverses them, and the interactive surface that triggers it is in — setup/interrupted.rs’s recover_setup, routed from setup.rs’s setting_up on a failed-apply and reachable from a bare lemonfiber, which is the same surface the wizard row above claims for its recovery routing. Still partial for two reasons that are about the journal itself, not about a missing surface. E4-R1 asks that every change be journalled, and one deliberately is not: the materialised stack is lemonfiber’s own regenerable output, rewritten identically on every run, and the wizard row above states that carve-out as a decision. E4-R2 asks that rollback operate on individual changes rather than only on points in time; Change::undo is genuinely per-change and doctor --undo reverses exactly one repair, but the only journal-wide surface is rewind, which unwinds everything most-recent-first, and nothing lets an operator pick an entry out of the history.
Backup & restore (quiesced capture, verify-before-replace, retention) E3-R1..R4, E3-R6..R14 The pure archive vocabulary and its policy landed: backup.rs — everything decidable about a backup without touching a disk, so the whole of it runs in a test with no services and no filesystem. plan decides what a capture copies from the install layout alone: whole-stack takes lemonfiber’s configuration (which defines the expected state), every service’s configuration and database, and the materialised stack; a single service takes only its own configuration directory (E3-R2/E3-R6). The media library, downloads, images and logs live outside this layout entirely, so excluding them is structural rather than a filter that could be forgotten (E3-R3). Every capture is marked sensitive because it carries the .env’s VPN key and provider passwords and each service’s API key (E3-R4), recorded in the Manifest that is written into the archive and read back to decide a restore. Compatibility::assess refuses an archive written by a newer lemonfiber with the version gap named (E3-R9), refuses one whose format schema or stated version cannot be read (E3-R8), and allows but warns about one a whole major version behind. Manifest::escapes refuses — before any file is opened — an archive whose member path or single-service name would traverse out of the tree it unpacks into, the pure half of “a corrupt archive is refused before modification begins” (E3-R8). relocation notices a restore to a different data root than the archive was taken against, compared as paths so a trailing slash is not a false re-point (E3-R10). Retention::keeping(n).prune prunes the oldest beyond n, raising a keep of zero to one so the last remaining backup is never pruned (E3-R11). The capture executor is now in on top of that policy: app/backup.rs’s capture drives the new Archive port in the order a safe capture needs — measure the room and refuse where the estimate plus a headroom would not fit (E3-R12), then write the plan’s items and the sensitive-marked manifest into the archive (E3-R2/E3-R4), then prune. Retention runs last and best-effort, per scope (the filename carries whether a backup is whole-stack or a single service, so a targeted service backup is never pruned because later whole-stack ones are newer) and never the archive just written, whatever a skewed clock made its recorded time. The Archive port’s write is contracted to replace atomically and refuse to overwrite an existing archive, so a stop part-way leaves nothing a later listing mistakes for a good backup. Quiescing the services before a capture (E3-R1) is the stop→capture→restart orchestration that wraps this. The restore executor is now in too: app/restore.rs’s inspect reads an archive’s manifest through a new Reader port and is the thing shown before anything is overwritten (E3-R7) — a corrupt archive whose manifest will not read is refused there (E3-R8), one from a newer lemonfiber is refused with the gap named (E3-R9), one in a format this build cannot read is refused (E3-R8), and one whose members would escape their area is refused (E3-R8); a restore onto a different data root is reported rather than refused (E3-R10). restore then unpacks the archive back over the install layout — each area to the same directory the capture read it from, via backup::destinations — only once those checks pass and, where the data root differs, only once the operator has accepted re-pointing (E3-R10); it is refused otherwise so paths that lead nowhere are never silently recreated. Stopping the affected services first and re-running seed to reconcile the wiring afterwards (E3-R13/E3-R14) wrap this the way quiescing wraps a capture. Both are now runnable from either surface: lemonfiber backup and lemonfiber restore build one of the core’s own commands (main.rs) and dispatch drives the executors over a real gzip-tar adapter (archive.rs, in the binary so the core keeps no archive dependency), handed to the run on its context and reached by the web surface’s backup and restore actions through the same commands. Refusing a stack that cannot be confirmed stopped is app/quiesced.rs, so it is the command’s rule rather than one surface’s, and what each run says is render/archive.rs. The adapter honours the port contracts the reviews pinned: it refuses to overwrite an archive already there and renames the finished file into place atomically; on the way back it sanitises every real archive entry itself — rejecting a .., an absolute path, or a symlink/hardlink escaping its area, and an area it does not know — before staging each area into a sibling and committing it one captured child at a time — so a single-service restore replaces only its own service and never wipes the others (E3-R6), moving any existing child aside first so a stop mid-commit is recoverable — and a corrupt or hostile archive is refused with nothing overwritten (E3-R8). Both commands honour E3-R1 by refusing to run unless the stack is confirmed stopped — a capture of a writing SQLite file, or a restore over one, is the corruption a backup prevents — and they fail closed: an engine that will not answer cannot prove nothing is writing, so an uncertain answer is refused as firmly as a running one rather than assumed safe. The operator is told to lemonfiber down first; the stop-and-restart convenience that would do it for them is a refinement. restore shows the archive’s contents and version before it replaces anything (E3-R7), refuses a re-point until --repoint accepts it, and on an accepted re-point now rewrites the restored .env’s DATA_ROOT to this machine’s so the configuration names a location that exists here rather than the one the backup was taken with (E3-R10 complete). Because a restore replaces state while the stack is down, the wiring and credentials are reconciled once it is back up: restore points the operator at lemonfiber up && lemonfiber seed to re-run the seed that reconciles the inter-service wiring (E3-R14) and at lemonfiber doctor --only credentials to re-validate the restored credentials against the live services (E3-R13) — both existing commands, composed. The automatic pre-operation backup (E3-R5) is reassigned to M9 (0.14.0), where its triggers — update, adoption, removal — land; it cannot exist before the operations it hooks. The auto-quiesce convenience and an automatic post-restore up→seed→validate remain, both awaiting a whole-stack lifecycle primitive — but neither is a distinct E3 requirement (the manual forms satisfy E3-R1/E3-R13/E3-R14). Every E3 requirement in 0.3.0 is met, so backup & restore is the complete, releasable half of 0.3.0.
Drift detection & seed policy (baseline + three-way comparison) C9-R1..R6, C9-R8, C9-R9, C9-R13, C9-R14 The baseline every comparison reads is recorded: baseline.rs is the pure memory of what seeding last wrote — per service, per field, the value and when — with record/expected and serde, persisted across runs beside the environment file (paths.baseline()), loaded at the start of a pass and saved at the end, the only memory of what lemonfiber wrote (C9-R1). On top of it, reconcile compares three ways — what lemonfiber last wrote (expected), what the service holds (actual), what lemonfiber would write (desired) — and returns present (at desired), drifted (differs while lemonfiber’s intent is unchanged, so the operator’s — preserved), stale (still lemonfiber’s baseline value while only its intent moved — its own, to bring up to date), or conflicted (both moved from the baseline — presented, never resolved); with no baseline to judge against, a difference is taken as the operator’s and left (C9-R2). It replaces the old two-way drifted() for download clients, so nothing that differs from the baseline is overwritten — a drifted value is preserved, a stale one reported, a conflict presented and left (C9-R3) — and an operator’s edit, differing from expected while lemonfiber’s intent is unchanged, is preserved (C9-R4). State gains stale/conflicted; a conflict joins refusals in the report’s blocked set (surface + exit code tell a conflict to settle from work to retry). A conflict now presents both sidesconflicted carries the value the operator set (yours, absent where they cleared it) beside the one lemonfiber would write (ours), rendered as the clash so the operator can see it, and the value is left as it is: presenting is not resolving (C9-R5). An operator’s edit can be adopted: each baseline record carries an origin (baseline.rswritten or adopted), and reconcile reads it so an adopted value the service still holds is adopted and kept, never reported stale or pushed back to lemonfiber’s default. The lemonfiber adopt command runs the wiring in an adopt pass, promoting every drifted value to adopted and recording what the service holds as the accepted baseline, which — sitting inside the configuration directory a backup captures whole — survives future seeds and restores (C9-R6). A value lemonfiber never wrote and has no baseline for is unmanaged — the operator’s own, outside lemonfiber’s scope — so an ordinary seed of an existing setup reports it as such rather than as mass drift, and the deliberate adopt takes it on as the baseline (C9-R9). An ordinary seed records neither a drifted nor an unmanaged value on its own, so a lost baseline is never silently frozen as adopted. The category comparison is semantic rather than textual: observe_client canonicalises each of the three legs before comparing, the same seam by which root folders disregard a trailing slash and applications a trailing slash on their base URL. What it canonicalises today is surrounding whitespace — a value differing only by it is not read as permanent drift — with case deliberately left alone (a category can be case-sensitive, and folding it could merge two the operator keeps apart); a service found to normalise a category further extends the same helper (C9-R8). The baseline still keeps the raw value it recorded. Loss of that record is told apart from a first seed: load_baseline distinguishes a genuinely absent file (a first seed, an empty baseline stands in) from one present but unreadable — its file unopenable or its contents unparsable — which is a loss; a lost record means drift cannot be assessed, so the pass reports that and offers re-baselining rather than guessing against an empty baseline, and it leaves the lost file untouched rather than silently replacing it, the deliberate re-baseline being adopt (C9-R14). The materialised stack files are covered the same way: app/materialise writes the embedded stack file by file, and a file the operator has edited by hand is detected by content — a checksum per file, recorded beside the environment file (materialised.rs, paths.materialised()) — and told from a version not yet upgraded: an edit is left exactly as they set it, never overwritten on an upgrade, and reported with a diff of what the upgrade would change (C9-R13, ADR-0005); a file still holding what lemonfiber wrote is upgraded, and an external stack is left entirely alone.
Drift detection & seed policy (severity, re-baselining, full reset, stale update) C9-R7, C9-R10, C9-R12, C9-R15 The confirmed full reset is in (C9-R12): lemonfiber reset reverts the operator’s hand-edits to the materialised stack files and, through the new download-client update op, writes lemonfiber’s own category back over a drifted connection in place — naming every file and connection it would discard first, and doing nothing until --confirm. Drift severity is in (C9-R7): a drift is reported as information and escalates to a warning only where it breaks the stack — a root folder whose host directory resolves to nothing (checked through the FileSystem port against the recorded data root), or a drifted download client the *arr’s own testall can no longer reach — each warning naming the breakage and its remedy, carried on every Wiring and drawn out by Report::warnings(). Schema-change re-baselining is in (C9-R10): a seed records each *arr’s own version beside its values, and where the version has changed since lemonfiber last saw it and every download client drifted at once, it adopts the current shape as the new baseline rather than reporting a wall of false drift — a partial drift across a version change is left as the genuine operator edits it is. And the state the table always said “will be updated” now can be (C9-R15): a value the service still holds exactly as lemonfiber wrote it, where only lemonfiber’s intent has moved on, is offered as a repair by the diagnosis and brought up to date on request — the one drift state with nobody’s edit in it, and the only one lemonfiber will write over.
Drift detection & seed policy (secrets withheld) C9-R11 A drifted secret is reported without displaying either value. The live surface turned out to be the hand-edit diff: materialised.rs’s diff shows the operator’s line marked - against lemonfiber’s marked +, and an operator who has pasted a credential into a stack file — an environment: entry is the ordinary case — would have had it printed twice by lemonfiber reset and by an upgrade’s confirmation. It is now withheld on both sides: the setting keeps its name, so the operator learns which one drifted, and neither value is shown. The predicate is config::store::is_secret and the placeholder its REDACTED — the same pair lemonfiber config withholds by, so one definition decides what counts as a credential everywhere. The separator is whichever of : or = comes first, because either can appear inside the other’s value and splitting on the later one would leave half the value in the name and print it. A diff reaches a terminal, its scrollback and any bug report pasted out of it, so a key that reaches one is a key that has to be rotated.
Download-client / root-folder / Prowlarr / Jellyfin→Seerr wiring D1-R7 Root-folder, download-client, Prowlarr app-sync and Jellyfin→Seerr identity drivers landed (see the orchestration row). All four are now driven against the pinned images rather than only built: a real Compose stack, seeded by this product, files each *arr into its own root folder, registers the download client into Sonarr and Radarr with its category, syncs both as Prowlarr applications, and points the request service at the media server. Two of them had never worked. The identity wiring was refused by the request service on every run it had ever made, and the download client was refused by SABnzbd, which recognises only its own container’s hostname and answers anything addressed by the name the *arrs use with 403 — reported by the *arr as being unable to connect, which is what kept it looking like a network fault. That one is fixed in the stack this pin now carries. Prowlarr app sync registers each media-filing *arr as an application in Prowlarr through a dedicated prowlarr.rs adapter and an AppSync port — Prowlarr speaks /api/v1, not the media *arrs’ /api/v3, so it is a client of its own — and seed.rs’s wire_applications observes, writes, reads back by baseUrl (not label) and journals, per the Prowlarr-application contract. Jellyfin→Seerr (D1-R7, D1-R16): jellyfin.rs drives Jellyfin’s first-run /Startup/* setup to mint and set the admin password (recorded under JELLYFIN_ADMIN_PASSWORD, the qBittorrent shape), and seerr.rs signs Seerr in through Jellyfin via auth/jellyfin; wire_jellyfin_identity reads back that Seerr reports itself initialised and never re-points one already set up (the household’s own — D1-R7’s consent case), per the Jellyfin→Seerr contract.
The book *arr is told where the indexers are D1-R15 Made, and proven against a live instance — which is what the deferral was waiting for and what changed the answer. The row read that this service’s Torznab API could not be pinned without one, and it could not; running one showed the premise was also off. It is not registered into as an indexer at all. It keeps its own list of aggregators and pulls from them, so the connection is made from the other end: it is told where the aggregator is and handed a key to read it with, at POST /api/v1/prowlarr. The body is camel-cased, and a field under the spelling its own storage uses is dropped while the request still answers 201 — an entry stored with no key in it, which is indistinguishable from a working one until nothing ever arrives. So the read-back counts an entry as made only where it holds a key. Its own credential is the first the stack mints before a service has ever run. Given one in its environment it adopts that value; without one it generates its own into a database nothing outside the container can read, and it will not take a supplied key on any later start. So it is minted where the services are started rather than while seeding, which happens after the service has already decided — on both start paths, since a stack brought up one way and not the other would leave it holding a key nothing else can present. Driven from nothing: minted, adopted, wired, and already wired on the runs after.
Quality presets in plain language D2-R1..R12 The whole preset story is in, pure model first and every service- and disk-touching slice on top of it. quality.rs is quality as the operator’s own question — how good, how much disk? — rather than the tool’s: four presets (space-saving / balanced / high-quality / maximum), each selectable by a plain label carrying no custom-format or scoring term (D2-R1) and each stating its practical consequence — the resolution it targets, roughly the disk an hour costs, and the transcoding it implies, the 4K-HDR maximum calling transcoding a likely cost while high-quality notes only that a weak client may (D2-R2). A Selection carries one global preset with per-media-type exceptions — the overridden state, read from whether a type genuinely differs — so film and television can differ (D2-R5), and it is forward-looking by construction. A preset is carried out to the services rather than scored bespoke: recyclarr.rs maps each preset to the community-maintained TRaSH-guide templates (quality definition, quality profile, custom formats) and app/apply writes them into recyclarr.yml, so the ranking is the guides’ and never a private scoring system (D2-R3). Changing a preset states plainly that it governs future acquisitions only — never a rewrite of what is already on disk (D2-R6) — and upgrading existing content is a separate, explicit action with its cost stated: app’s upgrade command runs each *arr’s cutoff-unmet search on demand, distinct from selecting a preset (D2-R7). Selecting a preset that needs transcoding on a host that cannot hardware-transcode warns before it is confirmed (transcoding.rs, gated on platform and Jellyfin mode) (D2-R4). An operator’s hand-edited profile is detected by reusing the C9 baseline and is not overwritten without explicit consent (D2-R8); an upstream guide source that cannot be reached leaves the existing profiles intact and reports the staleness rather than writing a partial update (D2-R9). The projected storage the preset implies is compared against the space actually available, warning where the projection is implausible (storage.rs) (D2-R10). Media with no resolution axis presents the right alternative instead: music and audiobooks choose a format (lossless / hi-res / compact) carried to Lidarr’s own quality profiles (lidarr.rs, audio.rs) (D2-R11). And a preset that yields no matching releases is told apart from an indexer failure — a clean empty search reports “few or none available”, an indexer that refused or could not be reached reports the failure, never the two conflated (D2-R12).
Pipeline trace — “where is my show?” D9-R1..R7 The core of the trace is in: one item followed across every service that handled it, in a single view (D9-R1). trace.rs is the pure spine — an ordered Stage enum from not-monitored to available, and, for each place an item can silently rest, the plain-language reason it stopped there, so the five confused cases are told apart: not-monitored, monitored-but-never-found, found-but-never-grabbed, grabbed-but-never-downloaded, and downloaded-but-never-imported (D9-R5). app/trace.rs assembles the view from the fragments each service holds: the *arr that monitors the item is the spine — servarr.rs’s Pipeline impl finds it by a human title, never an internal id (D9-R4), reads its stage-advancing history, and folds in the download client’s live queue — and jellyfin.rs’s Library impl reads the media server for the final available stage. Every stage names the service that recorded it, when, and the outcome (D9-R2), and a stage that stopped carries both the reason and the remedy — a queue stuck not-progressing, an import awaiting a library scan (D9-R3). Correlation is automatic: the *arr legs join on the service’s own item id, and the media-server leg on the item’s title (D9-R6). Because that cross-service title join shares no id, a trace that reaches available through it is marked uncertain rather than presented as fact — the first real use of the Confidence axis (D9-R7). The remaining D9 slices (D9-R8..R14) are below.
Pipeline trace — service disagreement D9-R8 Where two services hold contradictory views of one item, the trace surfaces the contradiction as a finding rather than reconciling it away. The first such case is an item the media server holds that no *arr is monitoring: app/trace.rs’s assemble reads the media-server presence even for an unmonitored item — not to promote how far it got (it stays not-monitored, and the trace is not marked uncertain) but to notice the disagreement — and adds a plain-language finding to the TraceReport saying the library has it while nothing maintains it. Findings are a channel of their own, orthogonal to the linear pipeline, so a contradiction is never flattened onto the single furthest-stage axis.
Pipeline trace — honest about what it could not read D9-R10, D9-R14 A read that fails is reported as unavailable, never inferred as nothing: app/trace.rs keeps each of the history and queue reads as read-or-not (a Fragments bundle), so an unreadable history is not taken as “indexers returned nothing” and an unreadable queue is not taken as “the client never took it” — the stalls those absences would earn are withheld and the gap is surfaced as a finding instead (D9-R10). The history a trace reads is bounded to the most recent trace::HISTORY_HORIZON events per service, and that horizon is stated in the trace output, so “nothing earlier” is honest rather than a claim about events beyond the window (D9-R14). The download-queue read now walks past the first page — a long queue can no longer hide an item and misreport it as stuck at grabbed.
Pipeline trace — history and the stuck-item landing point D9-R9, D9-R11 Repeated attempts for one item are shown as history (D9-R9): a new pure Outcome classifies the *arr’s history events — grabs, failed downloads, imports and removals — and says which advance a stage and which are only history; app/trace.rs’s assemble derives the pipeline stages from the forward-moving ones and keeps the full log beside them, so a release grabbed more than once or a download that failed and was retried shows as the pattern it is rather than flattened into the single furthest stage, and the render draws the history out only where it reveals what the linear stages cannot. And queue health links straight to the trace (D9-R11): lemonfiber stuck lists the items whose downloads are stuck, reading the queue with the series and movie included (servarr/pipeline.rs’s stuck_items) so each is named by the show or film a trace searches by rather than the raw release, and prints the exact lemonfiber trace "<title>" for each — so “N items stuck” is a named list the operator acts on one item at a time, not a count to investigate. A queue that cannot be read marks the list incomplete rather than being read as nothing stuck.
Pipeline trace — series and season aggregation D9-R13 A series is followed per episode and reported season by season, closing the gap the single furthest stage left: a show is “imported” the moment one episode lands, which reads as done while the rest are missing. trace.rs gains the pure aggregation — a Part carrying one episode’s own stage, and a Coverage grouping parts into per-season counts with the ones still outstanding named. Each part starts from the service’s current record (Stage::of_part: the monitored flag and whether the file is on disk), then is lifted by what was actually tried for it — a live queue record for that episode, or a grab its history proves. A file on disk settles it: an import recorded in a history the file no longer backs is stale news, not an episode that is here. The grab has to come from the history because the episode listing carries no usable flag for it — the television service defines a grabbed field on its episode type but never populates it on that endpoint and marks it non-serialising, so reading it would have reported every grabbed episode as one the indexers never found. The counts are of episodes someone asked for or already has: an episode nobody monitors and that is not on disk is reported beside the totals rather than folded into them, so a season of specials never drags a complete season down to a fault an operator would go looking for. servarr/pipeline.rs’s item_parts reads the episode listing, narrowing to one season at the service where lemonfiber trace "<show>" --season N asks for one; the queue read became one entry per record, since a series holds one per episode and flattening them made an episode downloading now indistinguishable from one grabbed and lost. app/trace.rs joins the two, lifting each part’s resting stage by what the queue is doing with it — the join that keeps an episode in flight from reading as a stalled grab. A film has no parts and reports no coverage; episodes that could not be read report as unavailable rather than as a series with nothing in it.
Pipeline trace — the household’s own view D9-R12 lemonfiber household reports what the household asked for and where each request stands, grouped by whoever asked — the same pipeline the trace reports in the services’ terms, in the words the person who asked would use. household.rs is the pure spine: the request service keeps two separate statuses — what became of the request, and what became of the media it asked for — and neither alone says where a member stands, so State::of folds the pair into one word (waiting for approval, declined, could not be fetched, on its way, partly here, here, removed since). A status this build does not know is reported as unrecognised rather than guessed into the nearest word: a member told “on its way” about something that will never arrive is worse off than one told the answer could not be read. No new secret is stored. Seerr authenticates its household against Jellyfin, so seerr.rs signs in with the media-server password seeding already minted and recorded (sign_in, extracted from the existing configure_identity so a read never finishes somebody’s setup as a side effect) and reads /api/v1/request as the owner — whose session sees every member’s requests, since members have no way to run this themselves. Requests are named through the *arr item id the request service hands over, an exact-id join rather than the title-fuzzy one the media server needs; each library is read once (Pipeline::library, now the single read find_items filters over) rather than a lookup per request. A request no service holds yet is named by what it is rather than given an invented title, and only a named one prints its lemonfiber trace "<title>" — a link to a search that would find nothing is worse than none. An unread record is reported as unavailable rather than shown as a household that asked for nothing.
First-content walkthrough D3-R1..R13 lemonfiber walkthrough adds one thing end to end and narrates every step of it, so setup no longer finishes at the moment of maximum uncertainty — sixteen green services and nothing proved. walkthrough.rs is the pure spine: the ordered Step a walk passes through (mapped onto the same trace::Stage D9 reports on, so the live walk and the after-the-fact trace never become two vocabularies for one journey), the State it can end in, what each step says in plain language (D3-R3), every way it can stop with a step, a remedy and the services’ own words inline (D3-R4), what is safe to suggest to someone with an empty library, and where it hands over when it works (D3-R10). app/walkthrough/ runs it: whether to offer at all, what to walk, asking for it, waiting on it, and what to say at the end — a file each, because each is a different set of services and a different set of ways to go wrong. The whole pipeline is exercised (D3-R2) through a new Catalogue port (servarr/catalogue.rs) that looks a title up, reads the operator’s own root folder and quality profile rather than inventing one, and asks the service to take it on and go and look. “Indexers returned nothing” is told apart from “indexers failed” (D3-R5) by reusing D2’s ReleaseProbe: an empty search is NothingMatched and a refused one is IndexersFailed, and only the second is reported as a fault. With torrents configured nothing is grabbed until the tunnel is proved (D3-R6) — gated on the VPN findings rather than the category’s overall verdict, which the not-yet-built killswitch test drags to “undetermined” for ever and would have closed the door permanently. The wait is bounded by the operator’s own patience: a download still moving is handed to the background with the terminal given back and nothing cancelled (D3-R7), and one that never started is a diagnosis. An import notes whether it linked or copied, with the consequence stated concretely (D3-R8), the media server is told to look so imported content is visible immediately (D3-R12, jellyfin/library.rs’s rescan), and something the stack already holds is detected from the service’s own id and something else offered instead (D3-R11). A household that acquires nothing gets a walk of its own — can the media server see the media you already have? (D3-R9). Setup offers it on completion and declining costs nothing (D3-R1); it is runnable at any time (D3-R13).

Exit criterion: after wiping config, lemonfiber up tv && lemonfiber seed restores full functionality in under 2 minutes, idempotently.


The P3 trust pillar made continuous, across three versions: 0.5.0 (how the product speaks — errors, notifications, health), 0.6.0 and 0.7.0. The VPN egress and killswitch proof, storage and hardlink verification, and queue health with its stuck-item categories shipped as 0.6.0; the provider checks, the support bundle and auto-remediation are 0.7.0. Those are recorded in the M3 table above, beside the setup-time checks they grew out of, rather than repeated here: a requirement named in two rows is one the release gate can read either way.

Every deliverable is in. This paragraph used to say the second half remained — the accounts underneath the stack, the bundle that makes a problem shareable, and the remediation that acts on what the checks find — while the six rows directly below it marked all three done, and the code bears them out. All three of the milestone’s versions are released, and every goal each of them locks is claimed by a ticked row.

What keeps this partial is the one thing no row can carry: the roadmap’s exit criterion is that each trust check catches its failure on a deliberately broken stack and states a remedy, and nothing performs that. Every check is proved against a scripted fake port; no workflow, just recipe, ignored test or container dependency exists anywhere in this repository to break a real stack and watch a check notice. It closes the way M1’s two do — when somebody runs it on hardware. See the spec roadmap.

Deliverable Spec Status Landing / notes
Provider health — what the accounts have left C8-R1, C8-R3, C8-R4, C8-R6, C8-R7, C8-R10, C8-R11, C8-R13, C8-R14 doctor/providers, over a pure provider model. Capacity is tracked beside validity, because a working login on an empty account authenticates perfectly and downloads nothing (C8-R1). A Usenet block is read from the download client that has been pulling through it — the only place the allowance and the bytes spent both exist — and depletion is warned about before it bites (C8-R3), projected from what the client actually measured day by day (C8-R4). The projection deliberately does not use the client’s own week or month totals: those reset on the calendar, so read on a Monday morning they would promise an account years it does not have. Where no allowance is recorded, what it has pulled is still reported and nothing is concluded from it — an inferred figure presented as fact is worse than an honest gap (C8-R6). None of it spends any of what it measures: both the client and the aggregator keep their own records, so the check costs the providers nothing (C8-R7). Each indexer is reported on its own (C8-R10), except when every one of them is failing at once, which is escalated as the local network problem it almost certainly is rather than as eight lapsed subscriptions (C8-R11) — an argument from a coincidence too large to believe, and so one that needs more than a single indexer before there is any coincidence to disbelieve. A subscription’s recorded end date is warned about before it passes (C8-R13), and a provider removed from the client stops being reported the moment it is gone, because every finding is derived from what the services hold now (C8-R14).
Provider health — what the provider itself says C8-R2, C8-R9, C8-R12 A Usenet provider states nothing about an account anywhere that can be asked, so its own testimony is read where it already exists: the download client records the sentence the provider gave when it refused, and how many connections it is holding. The three states are kept apart by their remedies (C8-R2) — a rejected login is fixed in the client in a minute, an account with nothing left needs topping up, and one that is not answering cannot be fixed here at all. What places them is the provider’s own vocabulary, in a trouble reading that mirrors the client’s own, so the two can never disagree about the same sentence; anything it cannot place stays unplaced, which is what makes a timeout structurally unable to become a rejected credential (C8-R9). The connection limit is reported from the refusal rather than from any published figure, because no provider publishes one (C8-R12) — and not while the client is holding every connection it opens, since an account allowing them all is refusing none. Two readings keep it honest: a held connection outranks any message recorded earlier, because clients keep the last trouble they saw until something replaces it, and holding none proves nothing at all — an idle client holds no connections to a perfectly good account.
Indexer caps and when they reset C8-R5 An indexer publishes its allowance almost nowhere — the limits in a Newznab capabilities document are results per query, not calls per day — so the cap that exists is the one the operator recorded in the aggregator after reading their own subscription, which is the same shape as a Usenet block: the allowance is held by the client, the use is measured by the client, and reading either costs the provider nothing. Both allowances are tracked, searches and grabs, counted over the aggregator’s own rolling window rather than since midnight, because a window that began at midnight reports an allowance as barely touched every morning however much of it went overnight. Feed polls count too: the aggregator keeps them in a column of their own and judges them against the same cap, and they are most of the traffic on a stack that is working. The reset is computed as the oldest counted call plus the window, from the aggregator’s own log — because at the cap the aggregator returns no results and records nothing at all: no error, no failure against the indexer, nothing in its API. That is the sneakiest failure in the whole feature, and this is the only place it is named. Where no cap is recorded there is nothing to judge use against, and the counts are reported with nothing concluded from them (C8-R6 again).
Provider health in the no-downloads diagnosis C8-R8 A trace that stops at “no search has found it yet” or at “the download client never took it” has hit an absence, and an absence is exactly what a lapsed account looks like from inside a *arr: an indexer with its allowance spent answers every search with an empty list, and an account refusing or empty takes nothing it is handed. So at those two stages — and only those two — trace asks the accounts, and what they say travels beside the stall rather than replacing it, because how far the item got is still the answer to the question that was asked. The provider check itself answers, rather than a second reading of the same services: it is already the judgment, and two paths to one verdict is one way for them to disagree. Deliberately not asked where releases were found and none met the quality preset — that is a preset asking for what the indexers do not carry, and sending its operator to look at their subscriptions would be the wrong half of the answer.
Support bundle C4-R1..R14 One command, one local archive. lemonfiber support describes before it writes: it collects, redacts and reads the result back for anything that still resembles a credential, then says what the bundle would hold and how large it is — and writes nothing until a second run says --write, because the decision to make a file worth attaching to a public thread is one to take after seeing what goes in it. Redaction is an allow-list, so a setting nobody has named yet is replaced rather than shown for want of a rule; free text gets the same reasoning where there are no field names to work from, losing query strings wholesale and any run that reads as a key. Each stand-in is derived from its value and salted per bundle, so one key reads alike throughout and means nothing in the next. Logs are bounded to a stated window and media filenames are replaced unless asked for; showing one setting as it is takes naming it and confirming it, and the bundle’s first page says so. Nothing is ever sent anywhere.
Auto-remediation C3-R1..R15 Offer, confirm, apply, re-run the originating check — and refuse anything that would overwrite an operator’s own change. The model is in: repair says what a repair would do and what else changes if it does, refuses to act at all unless the run said so, distinguishes a check that passes afterwards from one that merely ran, and stops offering a repair that has left the fault standing three times — handing over the support-bundle command rather than a shrug. Findings sharing a cause are answered once. Much of the rest was already here: the journal records every change well enough to reverse exactly it, a declined fix is not re-offered until the condition clears and recurs, and State already distinguishes what lemonfiber can mend from what only the operator can. All of that runs now: lemonfiber doctor --fix offers each repair with what it would do and what else changes and waits to be told, --fix --yes carries them out unasked, and a check that can put right what it found hands back a mender rather than a table somewhere else knowing how. A repair is proved by asking the checks again — assembled afresh, since a check holds what it read when it was built — and only that proof earns fixed. The one fault in the tree lemonfiber can mend is the download client left on a port the provider no longer forwards, which it re-reads rather than trusting what the diagnosis saw. A repair can also be taken back: lemonfiber doctor --undo reverses the last one and nothing else, so the wiring lemonfiber seeded and the choices the first run wrote stay where they are (C3-R15). The second mendable fault is a download client that has stopped filing where lemonfiber wired it — the one field an operator and lemonfiber both write, and so the only place a fix could write over somebody’s own change. It puts back lemonfiber’s own value fallen behind lemonfiber’s intent (C9-R15), recording what it changed in the shared journal so exactly that change can be reversed (C3-R6) — as its own kind of record, because a field inside a service and a setting in lemonfiber’s environment file read alike and are reversed nothing alike, and doctor --undo now reaches the service to put one back rather than writing the field’s name into a file; and it refuses to write at all where the operator has moved the value since, deferring to drift handling by name (C3-R7). The refusal reads the service rather than the baseline alone: “lemonfiber wrote this once” and “lemonfiber’s value is what is there” are different claims, and only the second makes a field lemonfiber’s to write again. Their edit is reported as information and raised to a fault only where the service can no longer reach what it points at, which is C9-R7’s rule rather than this feature’s.

The second surface over the same core (ratatui, per ADR-0003), spanning 0.8.0 (operating a running stack: forms, lifecycle, logs and the diagnostics that tie them together) and 1.0.0 (the interactive surfaces over it). The dashboard’s read-only model shipped under 0.5.0 and its screen and refresh loop are built — the two rows below say so — so what remains of the dashboard is its layout polish and a measured idle cost rather than a renderer.

0.8.0 locked 47 goals across B1 forms, B2 lifecycle, B4 logs and C1 diagnostics (spec versions/0.8.0.toml), and it is released — cut on 2026-08-22. Every one of those 47 is claimed by a ticked row below. This paragraph used to say B4 was very largely unbuilt: all thirteen of its requirements are implemented and reachable through lemonfiber logs --watch, which is a full ratatui screen with filtering, scrollback, export and restart notices.

The dashboard requirements moved off 0.3.0: every one is a display requirement and none is met without something to display on, so 0.3.0 ships backup/restore alone (M4). They did not all land in one place. The read-only model — B3-R2, R3, R5..R8, R11, R15 — is 0.5.0 and shipped. The surface and its interaction — B3-R1, R4, R9, R10, R12..R14 — are 1.0.0. See spec 0.5.0.toml and 1.0.0.toml.

Deliverable Spec Status Landing / notes
Live dashboard — the read-only model B3-R2, B3-R3, B3-R5, B3-R6, B3-R7, B3-R8, B3-R11, B3-R15 The pure shape of the screen and its assembler are in — dashboard.rs and app/dashboard.rs — so “what is my stack doing right now?” is assembled rather than fetched and runs in a test with no daemon. This row is the model underneath the screen; the screen itself is the row below, and it is built. What is left of the dashboard is not a renderer — it is the measured idle cost, and the two panel behaviours that have rows of their own. Two distinctions are types rather than rendering conventions, so a surface cannot collapse them by accident. A Reading keeps a current figure, the last one from a source since gone quiet, and a never-measured one apart (B3-R5) — and all three are now genuinely reachable: gather takes the snapshot it replaces, so a volume or a download client that answered a moment ago and did not this time keeps its last figure marked stale rather than blanking to unknown. Before that the middle state existed in the type and nothing ever constructed it. A Panel is either filled or carries the reason its source could not be reached (B3-R6/B3-R11), so one dead source marks its own region and states why rather than blanking the screen or showing absence as zero. Telemetry reads how the screen is doing from the shared health::Reach ladder, kept deliberately apart from how the stack is doing (health::Standing) since those disagree in both directions — a disconnected engine is never hidden behind a “degraded” that merely means incomplete (B3-R7), and gather never fails, so there is no error channel through which a dead source could terminate a render loop. The health line is the shared health::Summary rather than a second computation (B3-R2, G7) and is always present: an unreachable stack has a summary and it says unknown. The panel content types are the fragments no single service gives — Vpn (exit IP, country, forwarded port, and the egress-match that proves traffic leaves through the tunnel — B3-R3), Transfer, Queue, and Storage (free space, projected exhaustion, hardlink status — B3-R8). Every panel’s telemetry is gathered: services through the engine; storage read afresh each refresh with a volume attributed to no mount reporting unknown rather than a confident zero that would read as a full disk; the hardlink status from the storage check’s own empirical test_link probe, where an unwritable location or an unconfirmed link is never a met guarantee; exhaustion from the free space against the rate downloads are landing at, so a stalled queue projects nothing rather than an infinity; each *arr’s queue depth and stuck count through a Queues port; each download client’s transfers through a Transfers port with an adapter each side of the protocol split (qbittorrent.rs, sabnzbd.rs), each normalising its own idea of a download so the divergence stays out of the dashboard; and the VPN panel from the same exec-reads the leak check uses, so the panel and the diagnostic cannot disagree about what the tunnel is doing. A service still starting or one that will not answer is left out of its panel rather than failing it; only a stack that cannot be read at all leaves a whole panel unavailable. Durations come from one clock and never run backwards: eta yields no estimate for a stalled transfer rather than an infinite one, and percent clamps past-total skew rather than reporting more-than-finished (B3-R15).
Live dashboard — the surface, and the loop it refreshes in B3-R1, B3-R4 dashboard.rs (the screen), dashboard/panels.rs (what each one says), terminal.rs (the wire to a terminal and a person). The model and every gatherer have been in since M3 and nothing rendered them — this is the surface. A bare invocation on a configured machine opens it (B3-R1), and only where somebody is watching: a pipe, a cron line or a CI step gets the guidance text instead, since a dashboard drawn to nothing would never return. A refresh never holds up a keypress (B3-R4): gathering talks to Docker and half a dozen services and any of them may take seconds, so it runs as a task and the loop waits on whichever arrives first — the next frame’s facts or the operator’s key. The next gather is scheduled only after the last one finished, so a stack that takes three seconds refreshes every three rather than queueing gathers it will never catch up on, and an idle screen waits on a channel rather than spinning. The terminal is put back on the ordinary way out, on an error and on a panic alike, because raw mode is global state on a device this process does not own. What the screen says is pure over the snapshot and drawn into a buffer in tests — the only untested file is the terminal itself, which is the outermost edge and is excluded from the coverage gate on the same terms as the other three.
Idle cost — 1 Hz under 2% CPU, resident under 50 MB B3-R14 The cadence is built — one gather a second, scheduled only after the last finished, with an idle loop that waits on a channel rather than spinning. What is not done is measuring it. This is a budget, and a budget nobody measured is a hope: it needs a repeatable measurement of the running dashboard’s CPU and resident memory, and a gate that fails when either goes over. Recorded as its own row because naming an unmeasured requirement inside a ticked row is enough for the release gate to read it as done.
Forms and profiles — what the manifest guarantees B1-R1, B1-R2, B1-R8, B1-R12, B1-R14, B1-R15 A service declares exactly one profile, and the schema makes any other shape unparsable — the field is a required string and unknown fields are refused, so a service naming several does not parse and one naming none does not either (B1-R1). Forms are manifest data: resolving reads manifest.forms and nothing else, and the closure tests resolve a form this binary has never heard of, declared by a stack written for the test — which is the whole of what “adding a form needs no release” means (B1-R2). Profiles are not selectable: no surface accepts one, and a command line naming one is turned away by the parser rather than quietly narrowed (B1-R8). A form naming a profile the stack does not declare fails validation at load, with the offending form and profile named (B1-R12), and so does a depends_on that crosses a profile boundary — which is what leaves start order to Compose rather than to lemonfiber (B1-R14). Which profiles need a configured provider is the manifest’s answer through profile.protocol, never a name this code recognises: a stack whose download profiles are called nntp and swarm narrows identically, and the test says so in those words because the two ids were constants here once and a fork renaming either would have kept parsing, kept resolving, and quietly stopped being narrowed (B1-R15).
Addressing an operation B2-R4 Three ways to say what an operation is about, and every operation takes the ones that mean something for it. A form was always there. A service list is --service on up and down, joining restart and logs which had it; stopping named services reaches Compose as stop, which leaves them where they are, rather than down, which removes what the form started — different requests rather than one request with an argument, so Command::Halt is its own command. Everything is naming no form at all, settled in the one place a plan is made (closure.rs) so that every operation means the same thing by an empty list. It is deliberately not the union of every form: forms can refuse each other’s company, and composing all of them would refuse the moment a stack declared one non-composable form — which is exactly the stack most likely to want this. It is every declared profile, a property of the manifest rather than of any form, and still narrowed by what the configuration supports, so “everything” means everything the operator has set up rather than every container somebody could have set up. The half of resolving that does not care how the profiles were arrived at is now shared between the two, so naming forms and asking for everything cannot come to differently-shaped answers. Naming services suppresses the announcement, because the announcement is about what a form holds and “starts eight services” before starting two of them would be a sentence about a set the operator did not ask for. The -- fence that keeps a service name beginning with a dash from being read as a flag is now shared by all three aimed subcommands rather than by two of them.
Starting, and what a service is doing B2-R1, B2-R8, B2-R9, B2-R10, B2-R11, B2-R15 Starting waits for health rather than for a process: a container that is running but still inside its start period is exactly what a process check would have called success, and settle keeps waiting for it (B2-R1). The wait is bounded by the run’s own patience, and when it runs out the services still waiting are named with their own recent output attached — the explanation is almost always in it, and an operator who has to go and find it has been handed a fault report rather than a diagnosis (B2-R8). A container that keeps exiting and restarting has settled into crash-looping and waiting longer will not improve it, so it is reported as that rather than as something still starting (B2-R9). What each service is doing is one of eight states in docker.rs — absent, stopped, starting, running, healthy, unhealthy, crash-looping and failed — ordered worst to best so a form’s condition is a minimum across it rather than a table of comparisons; running is deliberately not healthy, because a service that cannot be asked is not a service that answered (B2-R10). --dry-run returns after the command is built and before it is run, so what it prints is the exact docker compose invocation rather than an approximation of one (B2-R11). And a service the operating system owns is host-managed: never waited on, never counted against a form’s condition, and never started or stopped here (B2-R15).
Starting — narrated while it happens B2-R2, B2-R12 A start was minutes of silence, and the silence was the whole problem: pulling an image, creating a network and waiting on a health check all look identical from outside, and the one that has hung looks identical to all three. So up streams Compose’s own narration as it arrives rather than swallowing it and printing a summary at the end (streaming.rs) — per-service, because that is how Compose reports, naming each container as it is created and again as it starts. Missing images come down in the same stream and are visible for the same reason: Compose pulls what it lacks before starting anything, and that is the part that takes the minutes. Setup’s first run already pulled with progress before starting, which is where the requirement was first met; what was left was every later up, which pulled silently inside a command nobody could see into. Starting leaves dispatch to do it, the way a pull, a watch and a log stream already have — Compose narrates for minutes and the report comes at the end, which is not a value that arrives once. The report is unchanged, and deliberately so: both paths settle what they are about through the same readied, so a streamed start and a waited-on one cannot disagree about which services a form holds, what would be left out, or whether the services became usable. The report is asked for whatever the exit status was, because a start that failed part way has still started something and which services came up is the first thing an operator needs in order to do anything about the ones that did not. A rehearsal narrates nothing, having run nothing. Under --json each line is its own envelope tagged start, kept apart from a pull’s so a consumer filtering the stream can tell two different things happening apart.
Operations say what they will affect first B2-R3 Starting already named the services it was about to start; stopping did not, and an operator taking a form down could not see what was coming until it had come. Both now say it, through one sentence that differs only in the verb — the services a form holds are the same ones whether they are about to go up or come down, so the closure is resolved once by the same code and read the same shape either way (affects). The verb agrees with however many forms were named, because naming two at once is the documented way to compose them rather than a corner. Said before the command runs, which is why it is its own entry point rather than a rendered outcome: at that moment there is no outcome, and the plan is the whole of what is known. Restarting names its services on the command line, so an operator has already said what it will affect; a switch reports all three directions of what it moved, and under --dry-run says them before moving anything.
Stopping — what is still coming down B2-R13 A torrent at ninety-four per cent does not resume where it left off on every tracker, and the operator almost never knows it is there: they are thinking about the form they named, and what is in flight inside it is not visible from the command they typed. So a teardown asks the clients first (inflight.rs) and names them one by one — “3 downloads still active” and a list naming them lead to different decisions, and the question an operator actually has is whether the one thing they have been waiting for is among them, which a count cannot answer. The client is named beside each, because “still downloading” is only half of what they need and a stack running both protocols has two places it could be. Then the offer: --wait lets them finish, --yes or a run whose input is not a terminal stops anyway, and an operator at a keyboard is asked. An unanswered question stops rather than waits — they typed down, so stopping is what was asked for and waiting is the courtesy; a prompt that blocked a teardown by default would turn a stray keypress into a stack still up an hour later with no reason to suspect it. Three properties keep the guard from being worse than none: only the clients the plan would actually stop are asked, so a form holding no client makes no requests at all and this stays off the common path; a client that will not answer contributes nothing rather than an alarm, because one already being torn down cannot be asked and a teardown blocked on it would fire exactly when it is least wanted; and a finished download is not in flight, since clients keep completed items in the same list they report active ones from. Waiting reports again only when the count changes — a list reprinted every ten seconds is something an operator scrolls past rather than reads, and the one moment it has news is when one of them finishes. Machine-readable runs are left alone entirely: a prompt has nobody to answer it.
One lifecycle operation at a time B2-R14 Two runs against one stack are not two operations that happen to overlap — they are two processes issuing Compose commands about the same containers, and what the stack ends up doing is decided by whichever Docker serves second. An operator who typed down in one terminal while up was still working in another gets a state neither command asked for, and no report saying so, since each reports only what it did. So an operation claims the stack before it starts and gives it back when it ends (lock.rs). The claim is one call, not a look followed by a write: between those two there is a window, and a lock with a window in it is not a lock — which is why FileSystem::claim is a port method rather than something assembled at the call site. The real adapter is a single create_new, which asks the kernel to create the file and fail if it is already there, and is proven against a real filesystem: the second caller is told it lost and writes nothing over the first. The port’s own default is the two-call version, honest about its window and written for the fakes, where nothing races. A refusal names who has it — the process and how long it has been going — because “locked” tells an operator nothing they can act on while a pid and an age tell them whether to wait or whether something died holding it. Nothing checks whether that process is alive: asking the operating system is a dependency and a portability problem, and an operator reading “four hours ago” already knows. --force is how they say so. A rehearsal claims nothing, changing nothing and so taking nothing away — being unable to rehearse during a real run would make the safe command the awkward one. The claim is given back on both ways out of an operation, which is the one hazard in this design: an early return between taking and releasing would leave the stack claimed by a run that has already finished. Both paths are covered — the dispatcher’s, and the streamed start’s, which does not go through lifecycle and so claims for itself.
Stopping — what another running form still needs B2-R7 Stopping asks before it acts, and refuses by naming the form that would be deprived: an operator told only “cannot stop” cannot act on it, and one told movies is still running can decide to stop that too — the refusal carries the command that stops both. The question is not the one superseding answers. Superseding is total containment, and two forms overlap without either being inside the other: tv and movies both reach the indexer, which does not care which of the two the operator had in mind. So needed_by intersects the closure being stopped with every form that is up, pure over a survey and testable without a daemon. Only running forms count: a form nobody started is not put out by losing a service it was never running, and refusing on its behalf would make stopping anything impossible on a stack that declares overlapping forms — which every stack does. Nothing is stopped when the answer is no, and the operator is told so. Asked only of a teardown: bringing a form up or restarting part of one takes nothing away from anybody.
Stopping — the tunnel goes down last B2-R6 A torrent client shares the tunnel’s network namespace, so the moment the tunnel stops the client has no network at all — mid-write, mid-announce, with peers it cannot tell. Stopping the client first costs a few seconds and leaves it able to shut down the way it knows how. Where lemonfiber hands Compose an explicit list of services — which narrowing does — that list is now ordered so whatever depends on a service goes down before it (stopping_order); where it hands over the whole project, Compose stops in reverse dependency order itself and the manifest already declares the client’s dependency on the tunnel. Read from each service’s own depends_on rather than from a rule about VPNs: the tunnel is the case that matters on this stack, but “stop a thing before the thing it needs” is the general shape, and a stack adding another such pair gets the same treatment without lemonfiber learning about it. Ties break by name so the same request always produces the same command, and services that need each other in a circle are still stopped — there is no order that satisfies a circle, and refusing to stop them would be worse than stopping them in one nobody can fault.
Stopping and starting — asking twice is not an error B2-R5 The stack is the state, not a record of what has been asked for, so an operator unsure whether a command landed can simply run it again: starting a form that is up, stopping one that is down and switching to the shape already in force all answer as they did the first time rather than refusing. Asserted by running each of the three twice against one context and comparing the answers, which is the only way to test a property that is about the second attempt. The no-op is also said: a switch that stopped nothing and started nothing reports “already in that shape” and still lists what is running, because reporting only the profiles would leave the operator to work out for themselves that nothing had happened. Starting and stopping report what they came to rather than a no-op line — Compose does the work there and a report of the condition is the more useful answer — so the explicit statement is made by the one operation that knows for itself whether anything moved.
Forms — what one would start, said before it starts B1-R3, B1-R6, B1-R7, B1-R9, B1-R13 lemonfiber forms tv answers what naming a form comes to without starting anything, and every lifecycle command says the same thing before it acts (B1-R7): the services it will start, in the stack’s own order, and each profile the configuration left out with the provider it wanted — a profile name on its own sends an operator looking for a fault they do not have. It is one answer rather than two. Plan is what a preview returns and what a LifecycleReport carries, so what was said beforehand and what a script reads afterwards cannot drift apart; under --json it arrives in the one document the command returns rather than as a second one. The closure is computed from the manifest throughout — forms, profiles, and now the services those profiles hold (B1-R3) — and a service two named forms both reach starts once, which the manifest guarantees rather than a pass over the list: the union is over profiles and a service declares exactly one (B1-R6). library resolves with no provider configured at all (B1-R13). A name the stack does not declare is answered with the form it was probably meant to be and then the listing (B1-R9), guessed at no further than one edit plus one per three characters — the list prints either way, and a confident wrong answer is worse than a list.
Forms — one service failing degrades, it does not roll back B1-R11 A service that never becomes usable stops the start being reported as finished and stops nothing else: no failure path anywhere issues a down or a stop, and the test says so against every command the runner was handed rather than against the one that came back — “nothing was torn down” is a claim about commands that were never issued, and cannot be read off the ones that were. The report names which service, attaches its own last twenty lines, says in as many words that the rest of the form is still up and was left alone, and states what the absence costs in the manifest’s own words: without_it is read from the stack rather than described in Rust, for the same reason the closure is computed rather than hardcoded — a stack that adds a service should not need a lemonfiber release to be able to say what losing it means. A service the manifest says nothing about contributes nothing rather than a placeholder, because an empty sentence is better than a wrong one.
Forms — narrowing the active set B1-R10 lemonfiber switch tv makes the named forms the active set and stops only what falls outside their closure. A service the old shape and the new one both hold keeps running rather than being restarted, because restarting it would interrupt work the operator did not ask to interrupt — a download in flight, a library scan half done. The decision is a pure function over what is running and what the new closure holds (app/engine/switch.rs), so which of three lists a service lands in is settled without a daemon, and the report says all three: what stopped, what started, and what was kept — the middle one being the point of the verb. Stopping happens first: what is stopped is by definition outside the new closure, and freeing its ports and its network namespace before the new set starts is what keeps a switch from failing on an address the old shape still held. A stop that fails stops the switch, rather than starting the new set over one that would not go down and leaving two shapes of the stack running at once. The stop invocation carries the profiles being left rather than the ones being arrived at — Compose accepts a service name only when that service’s profile is active, so the other way round would have built a command that ran and stopped nothing. What is up is surveyed across every profile the stack declares, since the services a switch must stop are precisely the ones the new closure does not hold and a survey of the new closure alone would never see them. Nothing the operating system owns is started or stopped; a service that is absent, stopped or already exited is not something to stop, and a crash-looping one is. --dry-run surveys and then prints both invocations in the order they would run, so a rehearsed switch is not a smaller claim than the thing it rehearses.
Form switcher — interactive picker with closure preview Pick a form (tv/movies/music) and see its composed closure before switching. The closure, the preview and the switch itself all exist and the CLI has all three — lemonfiber forms tv previews and lemonfiber switch tv narrows; what is missing is the interactive picker. No requirement is outstanding here — the row above carries them — so this is a surface over work already done.
Logs — a container cannot rewrite the terminal B4-R7 A log line is the least trustworthy text this product shows: written by somebody else’s container and printed verbatim, so an escape sequence in it is an instruction to the emulator rather than something said — \x1b[2J clears the screen, and the screen then stops saying what lemonfiber said. Every other line the surface prints already went through the one sink that makes text plain on the way in; a stream has no report to build, so it reached the terminal by another road and was the single line that skipped it. The decision now sits with the other renderers rather than at the println!, which is what lets it be asserted on: an escape can no longer alter terminal state, a newline can no longer forge a second log line (which the padded service column would otherwise make convincing), and the service name is made plain before it is padded, so a container whose name carries control characters cannot push the column out of true. Colour a container emits is lost with everything else a terminal would obey — the trade this module already makes for every other piece of foreign text, and a log line has no better claim to be trusted than a release name does. Machine-readable output was never affected: JSON escapes control characters.
Logs — severity read rather than guessed B4-R6 Nineteen services written by nineteen strangers each announce severity their own way — [Warn], WARN, level=error, ::INFO:: — and an operator scanning for the one line that matters should not have to know which service spells it which way, so a declared level is normalised to one word (logs.rs). Where a line declares nothing it passes through unclassified, which is the harder half: the obvious implementation treats standard error as error severity, and on this stack that is wrong for most services — the *arr apps, the tunnel and the Usenet client all write ordinary progress to stderr, and a viewer that paints all of it red teaches an operator to ignore red. So the stream a line arrived on is deliberately not an input. A level is the line’s first word, or the word after a key that says the next one names it: services put it first because that is where a reader looks, so the same word further along is the line talking about a level — Downloading Error.404.S01E01 is a release name. Timestamps are excluded by being unpronounceable rather than recognised (a word is a purely alphabetic run, which 2026, 21T19 and S01E01 are not), which is also why the delimiter does not matter and why a service nobody has heard of normalises on the same rule. Level is ordered so a filter can ask for warnings-and-worse without a lookup table, and its published word is pinned by test.
Logs — the evidence sits at the failure B4-R5 A check can say a service is not answering; only the service can say why, and an operator who has to go and fetch that has been handed a fault report rather than a diagnosis. So a finding in trouble carries what its service said lately and the diagnosis quotes it underneath. Starting already did this — a service that never became usable has its last twenty lines attached to the refusal — and the checks did not, so the same failure read two different ways depending on which command found it; the fetching is now shared and returns lines rather than text, so a report about several services tags each line with the one that wrote it while a report already naming one does not repeat itself. Passing findings carry nothing: evidence for something that works is noise, and on a healthy run it would be the bulk of the output. Absent rather than empty, too — a heading with no lines under it promises evidence that is not there. Read after the run rather than by the checks themselves, because a check is independent by construction and one that went and read a service’s output would be doing two things. What a finding gets depends on it naming a service, which today the credential check does and the others do not; widening that is a change to each check rather than to this, and the pass is written so that each one which starts naming its service gains its output without further work.
Logs — one account of what happened B4-R1 The engine gives one reader per container, so a scrollback of three services arrives as three bursts rather than as one account of what happened. Read back, the stamped lines are put in the order the containers themselves claim (logs.rs), each still tagged with the service that wrote it. That stamp is the only defensible ordering there is: containers disagree with the host clock and with each other, so an arrival time would be this process’s opinion rather than theirs. Compared as text rather than as instants — the engine writes RFC 3339 in UTC to a fixed precision, and such strings sort lexicographically in the order the moments happened — so the port’s promise to carry the stamp verbatim and unparsed is kept and no clock library gets an opinion about somebody else’s container. A line that does not say when it was written keeps its place: it has no claim to be before or after anything, and sweeping it to one end would be inventing an order rather than reading one. Scrollback only — a live stream cannot be sorted against lines that have not arrived, and arrival order is the only order it has. Which means lemonfiber logs --watch does not get it, and that is worth saying plainly: the viewer opens its tail with a following query, so the scrollback it starts with takes the branch that hands the stream straight back. The ordering reaches lemonfiber logs and not the screen, which is the surface an operator reads three services on.
Logs — read from the end, not from the beginning B4-R13 Historical logs are asked for as a tail, so the engine reads backwards from the end rather than handing over a file that on a service up for a month is a gigabyte where a screenful was wanted. Proven where the claim lives — on the request the adapter actually sends, driven against a socket written for the purpose: how_much_output_to_ask_for_reaches_the_engine asserts the path carries tail=42, alongside follow and timestamps. The engine does the reading, and asking it for a tail is the whole of how we avoid asking for everything; a test of our own options-building would only have proven we can build options.
A shortened value still says which one it is G3-R10 Five places shortened a value and changed what it meant; three survived this row being ticked. The log viewer padded the service to twelve characters and cut the rest, so calibre-web-automated read as calibre-web- and audiobookshelf as audiobookshe — both in the stack this ships with. The column is measured from the services that have actually appeared instead. The download report cut release names at forty from the end, which is where the parts that tell two releases apart live: cut there, ...1080p and ...2160p read identically, and a list of what is still downloading that cannot tell two downloads apart fails at the one question it exists to answer; it elides the middle and keeps both ends, marked with three full stops rather than an ellipsis so a terminal that cannot render the character is never handed one. Two more went on doing it while this row said otherwise, and both were found by drawing the screens rather than reading them. The dashboard’s panels reflowed and the lines inside them did not, so a release name was cut at its end at every width including a hundred and twenty — The.Long.Way.to.a.Small.An — the same failure this row had already described, on a screen it had not been applied to. The ? pane wrote each explanation as one line into a box seven tenths of the screen wide, so every definition ended mid-word at every width. They wanted different answers and the difference is the surface, not the taste: a value is shortened in the middle, prose is wrapped. A panel is a fixed-height box showing six entries and then a count, so a wrapped row would push the last entries out of a box that cannot grow — a marked loss traded for a silent one; the pane counts its own rows and so can spend another. The rule moved to text beside plain and wrapped, and every value the dashboard draws now reaches the screen through one function doing both the terminal-safety pass and the fitting, so no panel can put one there by a route that skips either — which is what “every place that shortens” has to mean for this row to stay true. Three further rows that were cut are not: the pane’s title, the count of words it had no room for, which gives up the sentence around the number before the number, and the reason a panel could not be filled, which is another service’s words. Nine guards over the drawn output of both screens, eight shown failing on the code as it stood — over the drawn output rather than the functions behind it, because in the line data the text was whole and only a terminal clipping it revealed the defect. The fifth was not a screen at all. A service’s HTTP error body was cut at two hundred characters from its end on the path every service client shares (endpoint.rs), so the shortening the screens had been fixed for was still being done to the detail a finding carries — and marked with a single ellipsis, the one character the rule next to it exists to avoid handing a terminal. An error body opens with boilerplate and closes with the failure it is reporting, which makes its end the half that names the cause: cut there, every verbose gateway failure reads as the same opening sentence and Connection refused by 10.0.0.5:8080. is the part that goes. It is shortened by fitted now like every other value, so both ends survive and the marker is the three full stops. A rule of its own was argued for on the grounds that an error body is prose read once rather than a value compared against another, and refused: where the two differ the middle elision keeps strictly more, and a second shortening rule in one binary is how the first four came to disagree.
One way out, and it renders for the terminal it has G3-R9 Output left through a hundred and six separate println! calls, which meant a question about how something is shown had a hundred and six answers — or in practice none: NO_COLOR had to be threaded to the one place that used colour, and the next such question would have been threaded somewhere else again. Every line goes out through say.rs now, and rendering gets asked once. Today it asks one thing: whether this terminal can show more than ASCII. Where it cannot, the symbols fold — and the marks fold to distinct characters, because six verdicts that read the same would be worse than six that look plain: + x . ! ? - carry what ✓ ✗ · ⚠ ? – did. Punctuation folds the way a typewriter would have written it. Folded only where the locale says so: C, POSIX, or a charset that is not UTF-8 are a terminal telling us what it can do; a locale that is simply unset has told us nothing, and guessing ASCII there would degrade the ordinary case to serve a rare one — the requirement asks for a fallback where Unicode is unsupported, not wherever it is unproven. The locale is read at the edge and understood below it: main.rs reads LC_ALL, LC_CTYPE and LANG in the order POSIX reads them, and what that answer means is decided in say.rs where a test can reach it — the same division the log viewer already makes over NO_COLOR, and the reason this one is stated is that it was originally written the other way round. The answer is latched, because it is a property of the terminal this process was given rather than of any call, and settling a second time returns what the first call settled rather than what this one asked for: a surface cannot change what a terminal can do halfway through printing to it, and being told that it did is how a caller comes to believe a fold happened that did not. What renders takes the answer as an argument rather than reading the latch, so a test can exercise it without settling a value that would decide for every test after it. A test keeps it one way out, which the conversion alone did not: a bare print added later still compiles and still reads correctly in review, and the line that skipped the fold would be the one still carrying a tick on the terminal that cannot draw one — precisely the terminal the fold exists for. The guard walks every file under a src/ directory and allows exactly two exceptions, for opposite reasons: say.rs is the funnel, and build.rs is not addressing a person at all, its output being a protocol Cargo parses that a renderer for human terminals would corrupt. It found two live leaks the conversion had missed, both in keyboard.rs — the one file where output and input meet, and the least watched, being one of the six the coverage gate excludes. Its question was written with a bare print! so that the answer could be typed on the same line, and its password prompt was handed to the rpassword crate to write, unfolded. Both are the setup wizard’s, which is where an operator meets the most unfamiliar symbols and the least likely place to want them raw. The funnel gained the shape it was missing — a question put out with no newline and flushed, since standard output to a terminal is buffered up to one and a question without it would sit unseen while the program waited to be answered — and the prompt the password crate writes is folded before it is handed over. A parser reads by the other door. Giving output one way out had put machine-readable output through the same one as a person’s, and folding damages it: the fold writes a curly quote as ", and inside a JSON string that is not a character but the end of one, so {"name":"The “Burbs” 1989 — 1080p"} came out as {"name":"The "Burbs" 1989 -- 1080p"}, which does not parse — and em dashes and ellipses were rewritten inside values whether or not the result still did. Not theoretical and not rare: folding is switched on by C, POSIX or any non-UTF-8 locale, --json exists for scripts, and a script is exactly where LC_ALL=C is set, so the two conditions seek each other out. It reached every --json path — the dispatch answer, watch, the walkthrough document, and the streaming emitters for logs and pull progress. Which door a line leaves by is carried by the lines themselves rather than passed to whoever prints them: the renderer that built them is the only thing that knows, and every print call site would otherwise have to be told and could be told wrong. for_a_parser neither folds nor makes plain. Making a document plain is necessary to skip, since it silently removes characters JSON permits; the reason given for skipping it was that serialising had already escaped every control character, and that was true only below a space — see the G3-R15 row, which closes what that sentence left open. A guard refuses serialised output put out through the folding door and pins that the parser’s door does not fold, by shape rather than by output: the fault shows only on a terminal that folds and only for a handful of characters, so a test of what was printed would pass on the machine of whoever broke it.
Colour is never the only thing saying it G1-R10, G3-R2, G3-R1 NO_COLOR is honoured, and the reason it is safe to honour is the part worth stating: colour was never carrying anything alone. Severity is a word in the line — WARN, ERROR — so a screen without colour says the same thing as a screen with it. The audit that preceded this found colour confined to exactly one file: the log viewer’s severity styling. The plain command-line output has no colour dependency at all — no owo-colors, no colored, no termcolor — and emits no escape sequences, so there was nothing there to refuse. The convention is the variable’s presence, not its value: set to anything but the empty string and colour is refused, so NO_COLOR=0 refuses it like everything else does. That surprises somebody exactly once and is what every other tool honouring it does; parsing it as a flag would be the surprise that repeats. Dimming survives, being an attribute rather than a colour. Read at the edge in terminal.rs, which is the only place that knows where the answer came from; what to do about it is decided in logs.rs where a test can reach it. The same rule holds on the web side, and there it is carried by shape rather than by a variable: every state draws its own mark — a tick, a dashed clock, a dotted question, a stop square, a half-filled disc — each an image with a name a screen reader announces, and the state’s word sits beside it in the eyebrow. A meter carries its value as a length and as a number, and gives up its colour under a forced palette rather than leaning on it. unknown is deliberately the one state with no colour of its own, which is the shape of the rule stated in the negative.
Log viewer — exporting what is on screen B4-R10 [e] writes the view out, through the support bundle’s own redaction rather than a second set of rules (logs.rs). An exported log is the same kind of thing a bundle carries — somebody else’s copy of what this stack said — and two redactors would be two chances to disagree about what a credential looks like. The redacting happens inside what exported returns rather than in whatever writes the file, which is the point: the thing the test inspects is the thing that lands on disk, where a rule applied on the way out of the module would be one nothing could check. On the bundle’s default terms, its most careful ones, because the viewer has no record of what the operator agreed to reveal and an export is read by whoever it was sent to. What goes in it is what the filter admits — an export is a copy of what the operator is looking at, and one that quietly carried the lines they had narrowed away would be a different document from the one they asked for — tagged service | line, which is the shape the bundle’s own log extract takes and therefore the shape its redaction was written against. Control sequences are gone before the text is ever assembled, because a file is opened by something eventually. Writing is asked for rather than done: the screen decides everything else without touching anything outside itself, so the one press that needs a file is reported to the loop instead, which also means e typed into a search is a letter like any other. An export cannot proceed without real randomness — a stand-in an operator can predict is a way back to the value it stands for — and the viewer says so in the stream rather than writing a file whose redaction is only as good as a fixed salt. Where it went is said in the stream too, for the same reason a restart is: a status row the next thing overwrites cannot say when.
Log viewer — a service restarting mid-view B4-R11 The moment an operator most wants a log viewer is the moment a service is restarting, which is also the moment its output stops — and a view that simply went quiet would leave them unable to tell a service that died from one that had nothing to say. So the viewer asks the engine what each service is doing, and a service whose state has changed since the last look gets a line in the stream, where it happened: a banner saying a service restarted cannot say when, and when is the whole of what makes it useful beside the lines around it. Said as what happened rather than as the engine’s word for it — Exited is a state, “has stopped” is news — and tagged with the service it is about, so an operator narrowed to one service keeps the reason its output stopped instead of losing it to the filter. The view is not disturbed: the notice arrives the way any other line does, so an operator reading history stays where they were rather than being thrown back to the tail by the very event they were reading about. Asked every two seconds rather than on the screen’s own tick, because a restart is not something anyone needs told within the second and this is the only call the viewer makes that the stack has to answer; awaited in the loop rather than on a task of its own, unlike the dashboard’s gather, since it is one call to the local engine socket rather than a dozen over the network. An engine that will not answer leaves the viewer’s account of the stack intact rather than emptied: silence is not “everything stopped”, and inventing that would put a wrong notice in the stream at the moment the operator can least check it. The first look is never news, which is what stops a viewer opening onto a notice for every service in the stack.
Log viewer — filterable, with scrollback B4-R2, B4-R3, B4-R4, B4-R8, B4-R9, B4-R12 lemonfiber logs --watch opens a screen over the same stream --follow prints, and a log viewer is mostly an honesty problem: it is given more lines than it can keep, from readers that produce them faster than it can take them, while the operator is looking somewhere else in the scrollback — and every one of those has a comfortable lie available that looks exactly like a service going quiet. So the scrollback counts rather than flags (viewer.rs) and keeps the three ways a line can go missing apart, because they call for different answers: truncated is age and wants a deeper buffer, outpaced is speed and no buffer will help, and unseen is nothing missing at all — the operator has scrolled away and it is waiting for them. One number for all three would send them after the wrong fix in two cases out of three. Filtering by service, declared severity and free text runs on the way out rather than the way in, so widening a filter shows the lines that were held all along instead of a hole where they used to be; a severity filter leaves out lines that declare none, which is the uncomfortable half of refusing to guess and is said in the doc rather than discovered. Scrolling back detaches and the tail re-attaches, and while detached an arriving line the filter admits grows the offset with it so the line being read stays where it was — the offset is counted from the newest rather than the oldest for that reason, and is the single truth the scrollback is told about, so the screen cannot say “detached” while sitting at the tail. Under a flood a pass takes a bounded number of the lines waiting and lets the oldest of the rest go, counted and stated: keeping the front of the queue would show a view falling further behind the longer the flood lasted, and an operator who cannot press a key to narrow the filter has no way out of it. A filter matching nothing says so with the number of lines scanned, because an empty screen reads the same whether the filter is too narrow or nothing has arrived. Typing a search is a mode of its own so that an operator searching for queue does not have the q close the screen out from under them. The state is decided in logs.rs and only drawn in logs/draw.rs, so every question the screen can be asked is answered without a terminal; what is left in terminal.rs is the raw-mode wire, now shared with the dashboard rather than copied. Colour follows the declared severity and leaves an unclassified line alone, for the reason B4-R6 gives.
Doctor view — interactive re-run, remedies inline The diagnostics as an interactive screen: re-run a check, read its remedy inline. No requirement is outstanding here — the checks are built and establish their findings empirically — so this is a surface over work already done rather than a claim on C1-R1.
Wizard in TUI — same state machine, richer presentation The setup wizard presented in the TUI, driving the same resumable state machine the CLI setup already runs. Not a claim on A2-R11: per-image pull progress and its expected-duration statement are built and shown by the CLI already.
Full-screen dashboard — narrow terminals, and lists too long to show B3-R9, B3-R12 Both were sitting inside a not-started row while being built and tested. A terminal under TWO_COLUMNS — ninety-six columns, two forty-character panels and their borders — is laid out in one column instead of two, and places gives every panel a place at either width rather than dropping any: less at a time and still correct, where the alternative is panels that overlap (B3-R9). Three tests hold it, including one that draws into a terminal too small to hold anything. And a collection longer than the screen is cut with the count it was cut from — rest appends “and 3 more transfers — 9 in all” to transfers, stuck items, queues and services alike, because a truncated list that does not say it was truncated is one an operator reads as complete (B3-R12).
Full-TUI layout polish B3-R10, B3-R13 What is genuinely left of the panel work. B3-R13 asks that any panel be viewable in isolation, and nothing offers that: the dashboard’s whole key map is quit, refresh and the glossary pane, with no notion of focus. B3-R10 asks that resizing reflow without restart or loss of scroll position — the layout is recomputed from the frame’s area every draw and the dashboard has no scroll position to lose, so it very likely holds, but no resize event is handled anywhere and no test exercises one, so at idle a resize is picked up on the next tick and nothing would notice if that stopped being true. Recorded not-started rather than partial because an unguarded likelihood is not a met requirement.

Exit criteria: the dashboard sustains a 1 Hz refresh under 2% CPU at idle, and input stays responsive while images pull. The read-only model, its assembler and every panel’s telemetry shipped with 0.5.0, and the ratatui render loop shipped with 0.8.0 — this paragraph claimed it was still to come while the row two above it said otherwise. What remains is the three surface-only rows above (the form picker, the doctor view and the wizard, each a screen over work already done), B3-R10 and B3-R13, and the exit criterion itself: the 1 Hz cadence is built and its cost has never been measured.

0.9.0 and 0.10.0. A third surface — a web view over the same core — plus the cross-cutting UX (front door, health summary, error model, plain language, accessibility, privacy, web-security, support bundle). lemonfiber ui serves that surface: the read endpoints, the actions endpoint and the event stream, with the app served beside them where a build carries one. What the browser draws lives in lemonfiber-web. See the spec roadmap.

Deliverable Spec Status Landing / notes
Error model — remedies, grouping, retry G4-R1, G4-R2, G4-R3, G4-R4, G4-R6, G4-R7, G4-R8, G4-R9, G4-R11, G4-R12, G4-R13, G4-R14, G4-R15 error.rs, retry.rs, adapters/retrying.rs. A Problem states what happened, what it means and what to do (G4-R1), at exactly four severities (G4-R2), under a stable Code (G4-R6), with the plain words leading and the technical detail available but never first (G4-R4/G4-R11); a remedy is required to construct one, and where none is known the problem says so and offers escalation rather than speculating (G4-R7); folded reports twenty occurrences of one fault once with a count (G4-R9); detail runs through the support bundle’s redaction so a credential cannot reach an error (G4-R8); a symptom can name the cause it came from (G4-R3); and remedies stay in the order they were offered rather than one being asserted as certain (G4-R12). Non-interactive errors go to stderr with a meaningful code and never prompt (exit.rs, G4-R13), and paths are shown in full (G4-R14). Transient failures are retried before anything is reported (G4-R15): a Retrying decorator wraps the real transport once — rather than a policy written into fifteen call sites, which is fifteen policies — and retries only what is safe to repeat. A POST that went unanswered may still have been received, so retrying it risks a second root folder; GET and PUT land on the same state however many times they arrive. A status code is never retried, because a status code is an answer. Three attempts inside a second, then the truth — bounded so a check somebody is watching stays responsive. What survives carries how hard it was tried, so “it did not answer” and “it did not answer every time it was asked” read differently.
Error handling cannot cascade G4-R10 This was recorded as unguarded and is not. The guard is a type, pinned by a test: Diagnose::problem returns Problem and never Result<Problem, _>, so there is no error path through error construction for a second failure to arrive on, and building_a_problem_has_no_failure_path_to_cascade_through binds all four construction paths — build, add detail, escalate to unknown, attach a cause — to explicit Problem annotations. If any of them ever gains a fallible form the test stops compiling, which is a stronger alarm than an assertion: an assertion can be deleted with the code it guards, and this one has to be answered before anything builds. It also asserts that a problem carrying a cause renders its own words rather than recursing into the one underneath.
Never the operator’s fault, swept over every message G4-R5 plain_language.rs reads every message this workspace ships — 3,133 literals in the production half of every source file — and refuses a fault named beside the person reading it. The join is what it refuses, not the second person: “The key is wrong, expired, or for a different indexer” faults a key, “You have changed this since lemonfiber wrote it” says what the operator did in order to leave it alone, and both stay legal — “The key you entered is wrong” does not. Twenty-seven words that name something done wrong, within four words of you/your, plus eleven constructions that blame without naming anybody (user error, invalid input, you should have). The corpus had to grow to reach this: a message written across a line break was invisible to the three guards that already swept prose, and 104 of them — 240 lines, among them the longest explanations this product gives — are now read as the one message each is. The sweep’s silence is not the only evidence: a_fault_is_blame_only_where_the_person_is_the_one_being_faulted pins four sentences shipped today that must stay legal and five that must not, so a list widened until it catches everything fails rather than looks green. What stays outside is stated rather than implied — another service’s own words, which lemonfiber surfaces verbatim and does not rewrite, and the web app’s strings, which live in lemonfiber-web. The six-phrase check against one synthetic problem is gone with it: it asserted about a problem the test had written itself, and the catalogue is what the rule is about.
Health summary — one computation, from findings G7-R1..R13 health/ (#201, #202). Computed from the conditions the checks raised, not from a count of running containers — the two come apart exactly where it matters, and the dashboard’s own Health::of(services) was on the wrong side of that: sixteen containers up and answering while torrent traffic leaves outside the tunnel graded as healthy. Now a critical finding outranks every reason to stay quiet (G7-R4), an unreachable stack reports unknown and never healthy (G7-R5), a deliberately stopped one reports stopped rather than a failure (G7-R9/G7-R12), and the worst is a max over the declaration order rather than an average (G7-R3). How much a failure matters comes from what the manifest says the service costs, so a failed subtitle fetcher is advisory and a failed download client is not (G7-R10). Every finding carries a remedy — Fault cannot be constructed without one — and the summary expands to the affected items and theirs (G7-R7). A finding downstream of another is folded into its root and counted once, so a filled disk and the nine imports it broke is one problem (G7-R6); never into a root less bad than itself, since letting the cascade rule bury the worst thing on the machine is the outcome the whole feature exists to prevent. Debounced on the clearing side rather than the appearing side (G7-R11): holding a new fault back would let an unverified tunnel read healthy for the first half-minute, so instead a fault that has come back before stays counted for a while after it clears. Landing it retired the dashboard’s duplicate Reach ladder and renamed its Standing to Telemetry — whether the screen is current and how the stack is doing are different questions that disagree in both directions (G7-R8). Conditions now persist in conditions.json, without which none of the above has a memory older than one process.
Plain language — the words, and a report that explains its own G2-R2, G2-R5, G2-R6, G2-R9, G2-R12 This ecosystem’s vocabulary is a wall — indexer, NZB, hardlink, retention, killswitch, ratio, all load-bearing, none guessable, and every definition written in terms of the others, so understanding requires already understanding. glossary.rs holds twenty-three of them with what each is for, never what it is: “a searchable index of Usenet articles” is a definition and answers a question nobody asked, where “search engines that find what you are looking for — you need at least one, and most cost a small yearly fee” answers why should I care, which is the actual question. A test enforces it, refusing any explanation that opens by naming its own word (G2-R2). The real word is the key of the entry rather than something the plain language replaces, so an operator who never learns indexer could not search for help about indexers — the explanation is a way in, not a substitute (G2-R5). Where the *arrs and SABnzbd disagree on words, the entry records what else the same thing is called — grab is snatch is fetch — and a test refuses to let one word be both a term of its own and another’s synonym, which would be two names for one concept in the very table meant to collapse them (G2-R9). They live beside the behaviour they describe rather than in a wiki, so the two version together and an explanation cannot drift from the thing it explains without the drift being in the same diff (G2-R12). A report explains its own words underneath itself (render/glossary.rs): the block is built from the finished report at the one seam every human answer already passes through, so a renderer cannot forget to do it, and it is appended after the --json return — a footnote is prose for a person, and appending it to a machine-readable answer would corrupt the one thing that answer exists to be. Only the short sentence appears there; the longer form is lemonfiber explain <word>, which nothing needs in order to act, and a word with no entry is refused through the ordinary error model with the words it does explain as the remedy (G2-R6). Two limits are deliberate. A report explains at most three words and names the rest rather than dropping them, because ten explanations at once rebuild the wall this exists to knock down and a footnote that silently covers some of them reads as “these are the hard ones” about an arbitrary three. And a token still joined by dots, underscores or hyphens once its punctuation is trimmed is taken as somebody’s name, not a word: report text is not all this product’s own — a release called Seed.of.Chucky.2004.1080p or a service called calibre-web-automated arrives in it verbatim — and explaining torrent seeding underneath a film title is the exact non-sequitur these explanations exist to avoid. A plural counts as the word. Matching only the singular looked right and was not: this product writes the plural far more often than the singular, and thirty of its sentences use nothing else — “there are no indexers configured, so there is nothing to search”, “hardlinks are not usable across an SMB share”, “this is what the indexers had”. Every one of them showed no footnote, and they include the sentences a first run puts in front of somebody who has never met the word, which is the whole audience this is for. Allowing the text’s word one trailing letter takes the sentences that find a word from 157 to 193, and all thirty-six of the new ones are real: indexers, hardlinks, torrents, quality profiles, custom formats, grabs. Only the text’s word may carry it, never the term’s — the other way round would let a term match a word that merely began with it.
Plain language — every surface explains its words G2-R1 The last two were the full-screen views, and they needed a different shape. A report can put its words at the bottom because a report ends; a dashboard does not, and every row of a log viewer is already carrying something — on a flood, exactly the rows being competed for. So ? opens a pane over the screen and any key closes it: it costs the screen nothing until it is asked for, which is also the difference between an explanation offered and one imposed. What it explains is what is on the screen now rather than everything this product knows — a glossary of two dozen words is a document, the four words in front of somebody is an answer — and it is read back from the lines that were built rather than from the values behind them, so a panel that worded something for itself is covered too, and a panel titled VPN counts as having put that word up. What does not fit is counted rather than dropped, because a pane quietly showing four of nine reads as there being four. The reach is now: command-line reports (a footnote after the answer), failures, the two words setup introduces, and both full-screen views. One reading is worth stating plainly: G2-R1 says inline, and none of these is inside the sentence that used the word — an explanation inside a sentence breaks the sentence. Every one of them is adjacent to it and reachable without leaving the screen, which is the reading this is built on.
Explanations can be put away, and turned off G2-R7 The feature’s own risk table names the case: an experienced operator finds it patronising, answered by explanations that can be turned off wholesale. LEMONFIBER_EXPLANATIONS=off does that, through the settings file config set already writes rather than a shell variable the product cannot see or report. On unless explicitly turned off, which is the right way round: somebody meeting this vocabulary for the first time does not know there is a setting to look for, while somebody who wants it gone knows exactly what they want to stop — the same shape reads_as_off already gives leak detection. Off means off everywhere, not shorter: no footnote under a report, none under a failure, no word introduced during setup, and ? opens nothing on either full-screen view. Settled once, in context(), because it is a property of the run and the two places that build a context are the only two that could be told — which means no surface can be told wrong. What is decided takes the answer as an argument, and the two full-screen views take theirs as a builder (without_explanations), so both halves of every gate are tested without settling a value that would outlive the test. Dismissible is the pane, which any key closes; a footnote already printed cannot be taken back, and turning it off is what dismissing it means for a report that has ended. And the narrative’s other half is now built too: collapsed by default once acknowledged, per term. lemonfiber explain <word> records that word beside the settings, and a later report names it rather than teaching it again — which also means the three explanations a report allows itself are spent on what is new. Once every word on a report is known the block collapses to the one line that keeps them findable, rather than disappearing: somebody who learned a word a fortnight ago still needs to know it is a word this product will explain. Acknowledged means an act of acknowledgement — not that a word went past on a screen, since plenty do unread, but that the operator went and asked. Anything looser would record a word as known because it scrolled by and then stop explaining it to somebody who never read it; it would also have every read-only command writing to disk, which is a surprising thing for ps to do and a race between two terminals besides. The record is written only where it changed, so asking twice touches nothing, and an unreadable one is treated as empty — the cost of being wrong here is explaining a word somebody already knew, and refusing to run over that would be far worse. A rehearsal records nothing — this record is not exempt from --dry-run changing nothing — and a lookup made with --json records nothing either, for a related reason: a script fetching the text is not a person learning the word, and recording a machine’s lookup would quietly stop explaining it to the operator who never made one. Opening the pane acknowledges what was behind it, on either full-screen view: pressing the key is the asking, so the words that screen was showing are recorded — closing it again is not, and neither is a key pressed on a run that explains nothing. The whole screenful is read and written once rather than word by word, because a screenful of separate rewrites is a screenful of chances for two of them to lose each other’s. Setup’s introductions deliberately do not, and the earlier note here that they would was wrong by this feature’s own test: a word introduced during setup appears — the operator did not ask for it, and recording it would be recording that something went past on a screen, which is the one thing this was defined not to mean.
Numbers carry the consequence, where there is one to carry G2-R4 Claimed by audit rather than by new work: every size, duration and rate this product shows was read, and each already says what it means or deliberately does not. The flagship conversion is doctor/headroom.rs, which divides free space by the preset’s bytes_per_hour() — “the free space holds about 14 hours of content at the balanced preset” — and where that is thin says why in full: content at this preset takes so much per hour, nothing already downloaded is affected, and new acquisitions will simply fill the disk quickly. A quality or audio preset states its rate at the moment it is chosen (“about 0.5–1 GB per hour”), which is the number that decides the choice. The dashboard turns free space into “full in ~3 days”, and a transfer carries its progress, its speed and its estimate together; the walkthrough’s narration does the same. A Usenet allowance becomes days from its burn rate, a refused lifecycle claim says “12 seconds after that one started” rather than a timestamp, and a pull says what it will cost before it starts — “usually a few minutes, and several gigabytes”. Where the consequence is not knowable, none is invented, and that is the requirement’s own qualifier doing real work rather than an omission: a transfer with no estimate says no estimate rather than guessing, and a support bundle’s size is stated bare because where an operator will send it is not something this product knows — it says “send it yourself” a line later. There is no guard for this and there cannot usefully be one: whether a consequence is knowable is a judgement per site, not a property a test can read.
Plain language — the shapes a false picture takes G2-R8 Two of them are refused now; the third cannot be. A simplification that leaves somebody with a false picture costs more than the words it saved, because they will act on the picture — and two shapes cause most of them. An analogy that does not hold: this glossary’s own rule is to explain by what a thing causes rather than by what it resembles, because an analogy invites the reader to carry across every other property of the thing it was compared to, and they will not stop at the one that was meant. A word that makes a real cost sound like none: simply and just are how a consequence gets talked past, against a rule that says accurate and longer beats simple and wrong. A test refuses both across all twenty-eight explanations, and it passed the moment it was written — the rules were already being kept, and what the test adds is that the next entry cannot quietly stop keeping them. What remains is whether a sentence is true, and that is not a property a test can read. It is held to when an entry is written and reviewed per entry, exactly as G4-R5 above is. Recorded ◐ as a settled answer rather than an open one: this is the residue after the shapes that can be seen have been taken out, not work waiting to be done. The one false picture actually found here was caught by reading — tracker recorded as another word for indexer, which the table itself contradicted — and it was a false equivalence rather than either shape above, which is the honest measure of what this guard does not cover. G2-R8 has been restated to name what can be read. It said “a simplification that produces a false mental model MUST NOT be used” when this row was written, which is why the row stood partial: the requirement named a harm no test can evaluate, so no build could ever exhibit it. It now says an explanation must not explain by resemblance and must not talk a cost away — the two shapes the guard already refuses. Nothing about the guard changed; what is claimed now matches what is held. The residue — whether a sentence leaves a true picture — is recorded in the specification as a reviewer’s job rather than carried here as an unmet requirement.
Plain language — the rules that are now tests G2-R3, G2-R11 Two rules that held by care alone now hold by test, in the shape #289 used for the output funnel: both passed the moment they were written, so what they add is not a cleanup but the guarantee that the next change cannot quietly undo them. One concept, one word (G2-R3): the glossary records what each other service calls a thing so an operator can follow one concept between their screens, and that record is not a licence to use either word — a product saying indexer on one screen and search provider on the next has told the reader there are two things to understand, which is the confusion the record exists to end. glossary::borrowed finds any of those names in a piece of text and says which word this product uses instead; the guard runs it over every string literal the product ships. Writing the guard is what exposed that three entries in that record were not other services’ words at allfetch is an ordinary English verb used six times in this product’s own prose, seeding is the gerund of seed, and profile names two different things here, since the services file quality profiles under Profiles while this stack also has Compose profiles deciding which services run. All three are gone, the last explained in its entry’s longer form rather than asserted as a synonym. No idiom (G2-R11): this product is read by people who did not learn English first and will be translated, and an idiom is the one kind of plain-looking sentence that cannot be worked out from its words — somebody who does not know that a ballpark is where baseball is played has no route from it to “approximate”, and no dictionary will carry them there. Thirty are refused by name; the list is deliberately not a claim to be exhaustive, since no such list exists. Both guards read prose only, since a word the product must not write is very often a fine identifier — nntp names a module, a type and a Compose profile here — so a guard reading whole lines would report the code instead of the words. Both also read only the half of each file that ships, and finding that half is now one shared production helper rather than two definitions that disagreed. The older of the two cut at the first #[cfg(test)], which several files declare near the top for a test-only helper module — render.rs on line 14 — so it had been discarding almost everything those files ship. The error-code uniqueness guard was reading that half: 4 of the 84 codes this product declares were invisible to it, three of them app.rs’s. None collided, so nothing was broken, but the guard had not been checking what it claimed.
Plain language — the words it had not explained G2-R13 An audit of every string literal this product ships found nine domain words used with nothing attached, and they are explained now: peer, backbone, block account, port forwarding, monitored, stalled, transcode, bitrate and HDR — twenty-three terms in all. stalled alone appeared thirty-six times. Service names were deliberately left out: SABnzbd and Prowlarr are names, not jargon, and a glossary that explained them would be explaining the wrong thing. Half of the rule is now enforced: an acronym is jargon at its sharpest — somebody who does not know NZB cannot infer it from the letters, cannot look it up under a word they never saw spelled out, and has nothing but the sentence around it — and it is the one kind of jargon a machine can pick out of ordinary writing. A guard refuses the whole class, so a new acronym cannot reach an operator without somebody deciding which it is: explained in the glossary, or written into an ORDINARY list with a reason, of which there are thirty-one across four kinds (met everywhere, the operating system’s own, units and formats, and ordinary English in capitals for emphasis). Three things wear capitals without being acronyms — an environment variable, a help-line placeholder, a name with a capital run inside it like SABnzbd — and rather than list them the guard looks only at literals shaped like something written to be read. The other half is not enforced, which is why this is not ticked. Lower-case jargon cannot be told from ordinary writing by any rule available here; a list of known jargon words would catch only what somebody remembered to list, and unlike the acronym rule it would force no decision — a new one simply would not appear on it and nothing would fail. So monitored and transcode were found by reading, and the next such word will be too. The acronym guard also passes over an acronym shown entirely on its own, as a bare label rather than inside a sentence. Recorded ◐ as a settled answer rather than an open one. A list of known lower-case jargon was considered and refused: unlike the acronym rule it would force no decision — a new word simply would not be on it and nothing would fail — so it would read as enforcement while enforcing nothing, which is worse than saying plainly that this half is caught by reading. What is enforced is the class that can be seen; what is not is named here so nobody has to rediscover which is which. G2-R13 has been restated to name the half that is enforced. It said a domain term appearing without an explanation “MUST be treated as a defect”, which describes how a team should regard a class of bug rather than a property of a build — unfalsifiable by construction. It now requires every acronym in shipped text to resolve to a glossary entry or to a word declared ordinary carrying its reason, which is exactly the guard below. Lower-case jargon staying unenforced is recorded in the specification beside the requirement, so the limit is written where the rule is rather than only here.
A failure cannot drive the terminal, and explains its own words G2-R10 The same fault as the log viewer’s (B4-R7 above), found in the one other place that reached a terminal by its own road. exit.rs’s complain printed line by line instead of building a report, so it never passed through the sink that makes text plain — and a failure carries text this product did not write: with_detail takes a filesystem’s fault.message and a service’s own reason, and endpoint::describe puts a service’s response body into a diagnostic string by design. The redaction those already passed through looks for credentials, line by line, and has no opinion about \x1b. So a *arr answering 500 with an escape in its body could clear the screen or write over the line just printed, and a diagnosis that no longer says what lemonfiber said has lost the whole of what it was for. Worth recording that B4-R7 is written about log output, so nothing in the specification covered this path; it is a defect fixed rather than a requirement met. The reporter now builds Lines like every other answer and puts them out once, which a test drives directly — an escape in both the summary and the detail, asserting the words survive and the instruction does not. Two things came with it. It had been rendering remedies its own way: Lines::remedy exists precisely so a diagnosis, a repair’s escalation and this cannot drift on how an action and its detail sit together, and this had drifted by writing the identical shape by hand with nothing holding the two together. And a failure now explains its own words (G2-R10), which matters more here than anywhere else: an error is where somebody is least able to go and look one up.
Every answer a script asked for is one it can parse G1-R7 --json offered a machine-readable form of every answer except the one a script most needs to act on. A failure arrived as several lines of prose on the error stream whatever was asked for, so a run that went wrong left a caller with an exit code — which says that something failed without saying what — and a paragraph it would have to parse by eye. Since the reporter had just been made to build its lines rather than print them, the shape was already there: a failure a script asked for is now one document carrying the same api_version, kind and data envelope every other answer uses, with the code, severity, meaning and remedies as fields rather than as sentences. Still on the error stream, because what a run was asked for goes on standard output and what went wrong instead belongs beside it rather than in it — a script reading the answer should not have to tell an answer from an apology. Who the output is for is settled once at startup, beside the locale and for the same reason: it is a property of the run rather than of any call, and twenty-six places report a failure. A flag threaded to all of them would be twenty-six chances to be told wrong, and the one that was missed would report prose to a script that asked otherwise — the case nobody tests. What is decided takes the answer as an argument rather than reading the latch, so both audiences are tested without settling a value that would outlive the test. The parser’s door exists on both streams now, and a guard pins that neither of them folds. This row was recorded partial first, because G1-R7 says CLI output and two commands still answered only in prose; both now answer. lemonfiber explain <word> --json gives the whole entry rather than the sentence, since a script has no way to ask a second time for the rest and the longer form and the other services’ names are the parts it could not have guessed. setup says what it came to: applied, abandoned, or nothing-was-asked, with the protocols, the data location and the service user it settled on. That report is deliberately not the settings themselves — setup writes an indexer key and a service password among them, and a report a script can read is one a script can log, into a file, a CI transcript, somebody’s terminal history. So it says what was decided and never what was entered, and its fields are chosen one at a time rather than by serialising a struct that might later gain a secret; the indexer’s address is left out for that reason rather than because it is itself a secret, since it is entered beside its key and the two travel together wherever an operator copies them from. One place decides the shape for all three endings, so a run that can finish three ways cannot answer in three. Every other command was checked one at a time rather than assumed — up, doctor, backup and restore each thread cli.json through a multi-line arm, which a line-based first pass had wrongly read as missing.
Live state — the stream a browser holds open ARCH-R49, ARCH-R50, ARCH-R51, ARCH-R61, ARCH-R62 events/. GET /api/events answers text/event-stream, where the event name is the envelope’s kind and the payload is the envelope — rendered by the Envelope::to_json that answers the command line, so the stream cannot describe a moment differently from the way a script would be told about it. It is the same gather (ARCH-R49): app::dashboard::gather, the call the terminal dashboard already runs, reached through a port whose one production implementation hands it the snapshot it replaces so a figure a silent source has stopped giving is carried forward marked stale. A second listener costs another subscriber, never another gather. That snapshot gained a kind and a place in the generated contract, so an SDK types the payload by its kind rather than parsing it blind. A silence says so (ARCH-R50, ARCH-R61): a comment line after fifteen seconds in which nothing else was sent, the wait starting again from whatever was last said, so a busy stream beats only once it has gone quiet. Proved by moving the clock rather than waiting on it — a test that takes a quarter of a minute to assert a heartbeat is one somebody eventually deletes. And a resumed stream does not lie about what it missed (ARCH-R51, ARCH-R62): every event carries an id of the run that minted it and its place in that run, so an id from a previous run is recognised as one rather than mistaken for a place this run has reached, and a client returning to a restarted server is restarted rather than told it is up to date. What is retransmitted is the record and never the state. A log line from during the gap is a thing that happened and is handed over; a snapshot from during the gap is a moment that has passed, and handing it back would be presenting a figure gathered before the gap as current. So the returning client gets what happened while it was away, and its picture of the stack comes from a gather made since it came back — which connecting asks for immediately rather than leaving it to the next tick. A gap wider than the backlog restarts rather than handing back part of a record, and a listener that falls too far behind to be caught up is ended so it comes back saying where it got to, which the backlog can answer without guessing. The route is merged into the one guarded tree router::routes assembles, and lemonfiber ui binds a socket and serves it, so a client reaches the stream by starting the surface. What is written here is what a listener is told and when — proved from outside the crate, with the clock moved rather than waited on.
Redirected output — plain text, said once G3-R7 Nothing checked what a pipe got. Two different faults could spoil it, and the worse one is quiet: a control sequence in a log file is not noise but an instruction, and a diagnosis somebody saves to send on can clear the screen of whoever opens it with cat. The guard runs the command itself — Cargo’s own binary, both streams on a pipe, an empty environment and a machine of its own, so a locale or a colour preference on the machine running the tests changes neither what is printed nor whether this passes. Six invocations, covering the argument parser’s own rendering, a report, a list, a glossary entry, the greeting and a refusal on the error stream; not one of them carries an escape, a carriage return, or anything else below a space that is not layout. The invocation that matters most is the bare one: on a machine already set up it opens the dashboard, which holds the terminal and draws in the alternate buffer, and in a pipe it gives four lines of guidance instead. The second fault is a line written over the line before it — a progress bar in a file, four thousand copies of one sentence — and there is no progress bar to write one: progress here is the subprocess’s own output forwarded a line at a time, and the wait that polls says a line only where it adds something, which now has a test on each side of it.
Nothing is reachable by mouse alone G3-R4 Two things hold this, and the first was verified by evidence that could not see the commonest way to break it. The sweep walks every place focus can land — 179 stories in five renderings, forward and then backward, because a control that swallows Shift+Tab is invisible to a forward-only walk and overshooting by one is the ordinary way a keyboard is used — and reads what focus drew at each landing, so a ring that is absent, transparent or narrower than two pixels fails. What it enumerates is what is already focusable: a[href], button, input, select, textarea, and anything carrying a tabindex that is not -1. A <div> with a click handler and no role, no key handler and no tabindex is not in that set, so it was never counted, never walked, and could not fail — the same shape as the offsetParent reading that once hid a fixed-position control from the same sweep. Driven with exactly that defect, eslint reported nothing and the sweep stayed clean; only svelte-check --fail-on-warnings refused it, which left a whole class of accessibility resting on a flag whose purpose is types, and left build, coverage and the story build compiling it happily. So the compiler’s own answer became the gate: the a11y_ family is refused as a family rather than as a list of codes, so a code a later Svelte adds arrives already refused, and it is wired into both the plugin and the guard that walks every component — a component nothing imports yet reaches no build, and the tree is walked regardless. Shown refusing in all four before it was relied on. svelte.config.js, scripts/warned.mjs, scripts/a11y.mjs.
A question is asked without a clock on it G3-R12 The requirement allows a time limit that is generous, stated and extendable — or none at all. There is none, and now something says so. Proving an absence needs a different shape from proving a behaviour: a question is only ever put where standard input is a terminal, so a test process, whose input is a pipe, cannot reach one to time. What is checked instead is the two things a prompt that expired would have to be built out of. Standard input is read in one file, which the guard also proves, so a deadline would have to be written into that file or into the modules that decide what it asks — and none of them names a clock, a thread or a channel. The sturdier half is the shape: asking hands back the words that were typed and nothing else, with nowhere to say that nobody answered, so no caller can quietly stop waiting without changing what asking means for every caller at once. Beside it, the rule that keeps this from mattering in a script: nobody who cannot answer is asked.
Severity is a word, and the colour is a second copy of it G3-R14 Two tests, because the claim has two halves and each can fail on its own. The severity a line is painted for is read from that line’s own words — never from the stream it arrived on, which would paint most of a working night red, since nineteen services here write ordinary progress to standard error. That is asserted against what the screen is given rather than against the parser, so it fails wherever the guess is made. And every severity that gets a colour at all is checked to be spelled on the screen as well, level by level, so a colour added for a sixth one is checked the day it is added. Together they are what makes refusing colour cost nothing: it was never the only carrier. The plain stream is the same story more simply — it adds no colour to a log line at all, which the pipe guard above establishes.
A long wait says what it is waiting for G3-R11 The wait that was the defect is the one after Compose returns: settle polls the engine twice a second for up to three minutes, is reached from every start and every switch, and emitted nothing — so lemonfiber up said what Compose said and then went quiet at the point an operator has most reason to think it has hung, which is the failure this feature’s own edge-case table names. It speaks now, every five seconds, through Narrator held on the context beside the other seams: “Still starting: audiobookshelf, calibre-web-automated, jellyfin, seerr — 5 seconds so far, of 180.” The list is the progress, shortening as services settle, and it is the same list the end-of-budget refusal names; the elapsed figure beside the budget is what makes the next line worth reading rather than a reprint of the one above it. Five seconds divides the poll exactly, so a line lands on a pass that was happening anyway rather than needing a second timer. The core still cannot print: the command line puts the words under the command it is running, under the same kind Compose’s own lines carry, and the web surface says them on the stream a browser already holds open, as state, so a client returning after a gap is caught up rather than replayed. Setup gains them too — its start goes through the buffered path and was silent from the end of the image pull to the end of the wait. A wait shorter than five seconds says nothing, and a rehearsal reaches the wait never. What is still quiet is doctor, whose checks run concurrently under stated fifteen- and thirty-second budgets — a bounded wait whose length is said in advance rather than an unbounded silence — and the archive commands, which have no polling wait at all.
The third surface starts, says what it is, and stops G1-R5, G1-R6, G1-R11, C6-R6 lemonfiber ui starts the web surface when it is asked for and not before — nothing is installed, nothing keeps running, and stopping it leaves nothing behind, which is what lets the most privileged surface in the product exist at all. It binds loopback and only loopback: this surface can start, stop and reconfigure the whole stack and reaches every credential the system holds. There is no default port — one this product chose would be the same port on every machine running it, and one nobody chose is one something else may already hold — so any free port is asked for and the whole address is printed. A port already held is reported with the address and a way out rather than being quietly swapped for another. The token is minted through the randomness port, printed once when the server starts, and kept nowhere else: no discovery, no file, no default. Launching a browser goes through the same process port every other program does, so a desktop with no browser, a machine with no desktop and an opener that exited badly are one answer — the address is printed either way and the command succeeds either way; the failing path is driven end to end by a runner that opens nothing. The transport is stated as a sentence rather than a scheme: http in front of a name is a fact an operator has no reason to be able to read, so what it costs them is what gets said. Two things that reach the world at a point a test cannot follow are taken as dependencies instead of called — the browser through the port, and the signal that ends the serving loop — so the surface is started, asked something, and stopped, with no terminal and no socket left behind. ui.rs.
The web API writes only what the command line can ARCH-R48 An action is named and the name becomes one of the core’s own Commands. That translation is the whole of the surface’s authority: a command is what the command line produces too, so an action reaching one cannot be something only a browser can do, and a name reaching none is refused rather than invented — a sweep over the offered list fails if one ever does. POST /api/actions/<name> turns a name into one of the core’s own Commands, and that translation is the whole of the surface’s authority: a command is what the command line produces too, so an action reaching one cannot be something only a browser can do, and a name reaching none is refused rather than invented. A sweep over the offered list is the guard — a name that reached nothing would be an action this surface alone had. Arguments mirror the command’s flags in one carrier, and a field no action takes is refused rather than ignored, so a caller who wrote service for services is told instead of watching a whole form stop. Reads are deliberately absent: asking what the stack is doing has an endpoint of its own, and a write surface that also answered it would be two ways to ask one thing. An action reaching the container engine or a service is answered with a name for the work and a 202, and the work is handed to the runtime rather than awaited — a browser tab closed mid-repair takes nothing with it, and where it got to is recorded against its name. An action confined to lemonfiber’s own files is answered with its outcome, in the identical envelope the equivalent command emits. actions.rs.
One envelope, and one rendering of it ARCH-R46, ARCH-R47 The endpoints serialise nothing. Each turns its path and query into the Command a person would type, hands that to the dispatcher the command line hands it to, and answers with the envelope that command renders — read.rs. A script piping --json and a browser fetching the same question therefore receive the same bytes, because there is one rendering and both read it rather than two that happen to agree today. The query half goes through one door too: what each read takes is named beside the command it reaches — reads/asked.rs — and a name no read takes is refused rather than dropped, so a request that misspelled key as keys is told instead of being answered with every setting there is. The reads that take no parameter at all are covered by the same table, which is what a check written per handler could never have reached: they had no query string to look at. A parameter carrying one value and given twice is refused for the same reason — answering for the first drops the rest without saying so. Both lie in how the request asked, so both are said through the reading below rather than through a second way of saying it.
A refused read says which refusal it is ARCH-R74 Every failure on the thirteen reads answered 500, so a word the glossary has no entry for, a form the stack does not declare and a container engine that is not answering were one answer — and lemonfiber-web’s glossary popover had to word a message true of all three while retrying the two that never will be. A problem now records where it lies — in what a request named, in how it asked, or in the answering — beside what it says, at the point it is raised, which is the only place that knows: error.rs. The surface reads that rather than keeping a list of codes on its side, because a list kept away from the thing it describes answers wrongly for every code added after it. What was named and this product does not have is 404, a request that could not be answered as it was asked is 400, and 500 is left to mean what it says. Setup’s own list of codes is gone, replaced by the same reading, so one refusal cannot carry two statuses depending on which door it came through — read.rs. The body does not move: the error envelope in every case, and where a problem lies is not written into it, so the contract artefact is byte-for-byte what it was.
What a request must carry before it is answered ARCH-R52, ARCH-R53, ARCH-R59 guard.rs. A secret minted per run, carried in X-Lemonfiber-Token and never in a URL, compared over every byte whatever it finds so the time taken says nothing about how much of a guess was right. Beside it, Host and Origin held against the address actually bound. Both must hold: the token is what a cross-site request cannot read and so cannot send, and the address check closes the window a rebound DNS name would open. A request naming no host at all is refused rather than excused. ARCH-R53 and ARCH-R59 are cited by no merged commit and cannot be. Both are held: admitted is one layer over the merged tree rather than one guard per route, exactly_one_path_is_let_through_without_a_token names the single exception, and the header is pinned by the name both sides agreed on. They landed in 6276921.
The contract is generated, and a stale one fails the build ARCH-R56, ARCH-R57 Contract::describe() builds each kind’s schema from the types that serialise the reply, so the artefact cannot describe a shape nothing sends — contract.rs. A test regenerates it and fails on any difference, which is what makes just contract a record rather than a ritual. A second test holds every kind the emitters name against every kind the contract describes, so describing one nobody sends and sending one it omits are both caught. ARCH-R57 is cited by no merged commit and cannot be. The comparison is a test, so a stale artefact fails the build rather than the program that emits it, and the test runs in the same workspace run CI gates on. It landed in d2bf74b.
An SDK generates from the artefact and reaches nothing else ARCH-R58, ARCH-R64, ARCH-R65, ARCH-R66, ARCH-R67, ARCH-R73 release-contract.yml attaches the artefact to the release that produced it; each SDK vendors it at a full revision, an abbreviated hash being refused because it names one artefact today and may not later. Generation reads the vendored copy and touches no network, regeneration must move no byte or CI fails, and an artefact whose api_version the SDK does not implement is refused by name rather than turned into types that compile and lie. It also refuses one whose meaning depends on the reader: a constraint sitting beside a $ref means both to a 2020-12 reader and only the constraint to a draft-07 one, and each generator resolved that differently — the TypeScript one kept the constraint and dropped the reference, the PHP one did the exact opposite. Both produced output that compiled and analysed clean, which is why neither said so. Each now enumerates every such site and stops, naming where it occurs, rather than generating from the half of it that it reads. An annotation is not a constraint, so a described reference stays ordinary company. A change to the artefact on main tells each SDK to compare what it vendors against what is now served, so the copy is checked on the change rather than on the calendar — contract-moved.yml.
A client reads what it was handed, or refuses it ARCH-R55, ARCH-R60, ARCH-R63 Both SDKs refuse a version mismatch naming both numbers rather than rendering part of an answer, refuse a base address that is not loopback while still accepting one written as a name rather than as four numbers, and type a payload by its kind. The last was unsatisfiable for nineteen of twenty-five kinds until the vendored artefacts caught up: each SDK carried a six-kind copy, so its Kind was closed at six and the rest fell outside it entirely.
The frontend is embedded, and serving it is not the core’s business Two halves, split where the honest seam is. frontend.rs in the core is a path in, bytes out — the same shape stack.rs beside it already has, embedded from a pinned submodule or read from a directory, with everything above it unable to tell which. It answers two questions that are about the app rather than about HTTP: a path climbing out of the directory is refused rather than resolved, and a path naming no file at all is the app itself, since its own router reads the path once the page is loaded. The core has no HTTP server and does not gain one. frontend.rs in the api crate answers what a browser is told a file is and what it may do with it: the type, which decides whether a stylesheet is applied or downloaded; no-store throughout, because a browser holding yesterday’s app draws today’s fields under yesterday’s meanings; and the two headers that stop a page holding a token being sniffed or framed. The app itself carries no token — a browser opening a page sends no header of ours, and it is the page that goes on to ask for one — while everything below /api is admitted once, above all of it, so an endpoint added later cannot arrive unguarded.
Every admin service answers this machine and nothing else C6-R1, C6-R3 Every service in the admin tier publishes on 127.0.0.1 and no other address — sonarr 8989, radarr 7878, lidarr 8686, bazarr 6767, bindery 8787, prowlarr 9696, nzbhydra2 5076, flaresolverr 8191, qbittorrent 8081, sabnzbd 8085. Not one is bare, none is 0.0.0.0, and none is written through a variable that could be set to either. The stack does carry a LAN_BIND knob, and it reaches only the household tier — jellyfin, seerr, calibre-web, audiobookshelf, homepage, caddy — which is a different rule and a different row. The distinction is the point: what an admin reaches can start, stop and reconfigure the stack, and what a household reaches cannot. lemonfiber’s own surface is bound the same way and by the same reasoning. Read back by a test now rather than held by convention. publishing.rs reads the stack this binary carries — the embedded copy, because that is the artefact and the directory beside it belongs to another repository a clone need not have populated — and refuses any published port that is not 127.0.0.1 unless its service is named with the reason it is reachable from the network. A mapping written with no address at all counts as beyond loopback, which is what Compose does with it. The list is not a second opinion: stack.toml already declares a tier per service, and the test holds the exception list to exactly the services the manifest calls lan, so a pin move that reclassifies one is red here until somebody writes down what changed. The stack’s own repository checks the same property, on its own pull requests, which is not the moment a stack arrives here.
What a cross-site request cannot send C6-R10 There is no cookie and no session anywhere in the surface, so a request carries no authority a browser would attach on its own — which is what a cross-site request forges. A secret is minted per run, printed once, kept in no file and no default, and must arrive in X-Lemonfiber-Token, compared over every byte so the time taken says nothing about how much of a guess was right. A custom header is also a header no simple cross-site request may set without being asked first, and nothing answers that question. Beside it, Host is held against the address actually bound and Origin against the same where it is sent; a request naming no host is refused rather than excused. The check is one layer over the merged tree rather than one guard per route, so an endpoint added later cannot arrive unguarded — guard.rs, serve.rs.
Nothing is carried through to a service’s own interface C6-R12 The surface answers questions about the stack; it never becomes a way to reach the programs in it. That is held by construction rather than by intent, and asserted three ways in nothing_is_carried_through.rs: the crate’s manifest may not declare an HTTP client at all, so it cannot open a connection; no file in it may name the adapters layer, which is where reaching a service lives; and no line it ships may carry a scheme followed by a host, so there is nowhere named to reach. The browser half is closed too — the app’s own documents are served with connect-src 'self' and frame-ancestors 'none', so a page holding a token cannot be framed and cannot be made to fetch elsewhere.
The password this surface asks for, kept as what proves it C6-R8 There is exactly one credential in this product lemonfiber verifies rather than reads — the operator’s own — and it is kept as an Argon2id verifier and never as the password. admission/credential.rs. The costs are the ones the function’s own crate defaults to and OWASP recommends, 19 MiB of memory, two passes, one lane, and memory is the whole of why: a hash that costs only arithmetic is one an attacker runs on a graphics card by the billion, and one that costs 19 MiB a guess is bounded by memory bandwidth, which is the resource that does not get cheaper by the rack. The id variant is taken over either half — the first pass is data-independent, so a process sharing the machine learns nothing from the access pattern, and every pass after it is data-dependent, which closes the shortcut a purely data-independent function leaves open. There is no key that would turn it back, which is the other half of the requirement: encrypting the record at rest would move the question to where the key is kept, and a key that can decrypt is a key that can leak. The whole choice is pinned against the artefact rather than against the constants that produced it — a test reads $argon2id$v=19$m=19456,t=2,p=1$ out of a real record, so a dependency bump that quietly lowered any of the three is red. A second test holds a record the previous major version of the hashing crate wrote and proves it still opens the door for its own password and still refuses every other, so a bump that moved the format, the costs or the salt encoding is caught here rather than on the machine of an operator who can no longer get in. The salt arrives through the randomness port every other secret does, and a source that will not answer leaves no record rather than a weak one: nothing at all, fewer bytes than the function will salt with, and more than a record holds are three separate refusals and each is proven, because a narrow salt is invisible in the result — it looks exactly like a wide one right up until two machines write down the same password the same way. The costs travel inside the record, so raising them later is a change to what is written next rather than a migration of what is already written; and a record that cannot be read proves nothing rather than everything, which is the direction a damaged file has to fail in. Debug is written out rather than derived, so the verifier cannot reach a log line or a pasted error by having been printed. It is written owner-only through the same write every other small lemonfiber file goes through, beside the settings so a backup carries it — a restore that silently dropped the password would put a surface back with nothing in front of it — while a support bundle does not carry it, because a bundle lets fields through by name and this file has none on that list. Nothing else in the core may hash a password its own way: argon2 is confined to that one file by architecture.rs, because a second place doing it would be a second set of costs and the weaker of the two would be invisible in everything it produced. The password is asked for at the keyboard and twice — never on a command line, where it would be in the shell’s history and in this machine’s list of running processes, and twice because nothing here can read one back afterwards, so the second answer is the only check there is that the first was typed the way it was meant. ui/password.rs, reached by lemonfiber ui --set-password and by a fourth row on the box the dashboard’s w key opens — that row carries whether one will be asked for and never the answer, since every line typed at that screen is drawn into a box whoever is standing behind the reader can see, so the asking happens on the ordinary terminal the screen has just given back. What a stored password goes on to permit is C6-R4’s, and is not claimed here.
The second way in, and what ends it C6-R9 The password is exchanged once, for a session, at the one route on this surface that answers a request carrying no token — which it has to be, since a caller holding a password and nothing else carries none by definition. Once because verifying one costs 19 MiB and tens of milliseconds on purpose, and because a credential re-sent on every request is a credential with more chances to leak. What comes back travels in the same header the per-run token does, so the guard reads one credential header and a client holds one thing rather than two — and a read carrying a session is answered exactly as one carrying the token is, which is the whole of what being given one is worth. POST /api/session, answered with the envelope under a new admission kind, generated into the contract artefact like every other. admission.rs, sessions.rs. Two things end a session, and neither is a request to end it. It expires, on an absolute clock rather than on use: a sliding window renews itself for as long as anything keeps touching it, so a browser left open on another tab is indistinguishable from a person still sitting there — and the case this exists for is a phone somebody put down. And a password change voids it, because each session remembers the credential it was opened against and is checked against the one on disk now — which is what makes changing the password a way to end a session somebody else is holding rather than only a way to stop the next one, and it needs no message passed from wherever the change happened, which matters because that can be another process. A password removed ends them the same way: nothing on disk means nothing to have been opened against. Nothing outlives the run, for the reason the names given to work do not — a record that did would admit somebody to a surface that is no longer serving. The route sits under the same one layer every other route sits under, named there rather than merged outside it, because a second tree merged beside the first takes the fallback with it and that fallback is what refuses a path under /api/ that nothing serves. It keeps the other half of the guard: a request must still say it came from where this server is listening, so a page the operator happens to be visiting cannot post guesses with their browser. Exactly one path is let through, held by a test that reads the guard’s own source rather than by trying the paths that exist today.
Guessing costs time, and is told how much C6-R11 Failed answers are counted and made to wait. attempts.rs. Three are free, because typing one wrongly is what people do; after that each one doubles the wait, up to five minutes. Doubling is what turns a list of ten thousand common passwords into a wait nobody sits through, and the cap is what stops one afternoon of guessing from locking an operator out for a week. The limit is on the surface rather than on whoever is knocking: there is one password here, and a caller choosing a new source address per attempt is the ordinary shape of the attack, so a limit kept per address is one an attacker steps around and an operator behind one address runs into. What that costs is that somebody guessing can keep the operator waiting, and on a household network that is the better half of the trade — the wait is bounded, it is the same wait for everybody, and the alternative is a password an unbounded number of guesses reaches. It is said out loud rather than left to be discovered: 429 with the wait in Retry-After for the client and in the sentence for the person, because both of them are here — the page shows one and the client behind it waits on the other, and a client told nothing retries into the limit and extends it. A right answer forgets the count, so the next mistake starts from nothing again; a body that was never an answer is not a wrong answer and costs nothing. A wrong password and a machine with none set are answered with the same sentence under the same status, because they are the same fact to whoever is knocking, and saying which of the two it was would tell somebody guessing whether there is anything here to guess at. 401 for both rather than the 403 every other refusal answers with: 403 means nothing you could send would help, which is true of a missing token and false of a wrong password, and a client that cannot tell them apart cannot know whether offering a login is worth anything.
Offered to a network, or refused C6-R4, C6-R5 lemonfiber ui --lan offers this surface to the network, and it is refused — not warned about — unless a password has been set. A warning that can be clicked past is how unauthenticated control surfaces end up on networks. It is also not quietly served narrower instead: an operator who asked for the network and was handed loopback finds out from a device that cannot connect, which is a worse way to learn it than being told now, so the refusal names both ways out — set one, or reach it from this machine. The refusal and the giving-up are one rule read at two moments. reach.rs’s permitted is that rule and the whole of it: what may be reached is a function of what was asked for and whether a password is set, and nothing else. Before a socket exists there is nothing to fall back to, so a request that fails it is refused; after one exists, refusing outright would take the surface away from the operator too, so it falls back to the address it would have been given and says why. Two answers, one predicate, neither written twice — and all four of its readings are asserted, because what makes it a policy rather than a condition is that there is an answer for each and only one of them is a refusal. The removal is immediate in the sense that matters and prompt in the other. Every session was opened against the credential on disk and is checked against the one on disk now, so from the moment the password goes nothing on the network is admitted — no interval, no message passed, and true even when the removal happened in another process. The socket itself is given up at the next look, which is every five seconds and is stated as such: what that costs is a look at one small file, and what it buys is a binding that follows the authority rather than outliving it until the next restart. Proven over a real connection rather than by reading the code back: a request naming an address this machine is not, answered while it is offered to a network and refused the moment it is not, with this machine still reaching it either way. The loop that does it holds every socket open through one shutdown signal and gives them all up before asking again, so the next binding is not asking for a port this run is still holding.
The policy is one decision on both families C6-R14 A tier names an address on IPv4 and one on IPv6, and every one that can be taken is taken: 127.0.0.1 and ::1 for this machine, 0.0.0.0 and :: for a network. A policy enforced on one family and silently absent on the other is worse than none, because it reads as enforced — and the operator who checks their firewall against it is checking the half that was written down. The IPv6 address is asked for first, deliberately: a wildcard on that family answers for IPv4 as well on some machines and not on others, so asking for it first means the machines where it does are covered by one socket rather than by a second bind that then has to be explained away. What is printed is what was actually taken, not what was meant, so an operator on such a machine is told about one socket because there is one. A family this machine has no stack for and a family already answered by the socket before it come to the same thing: one fewer socket, and nothing to report; taking none of them is the failure, and it is the one that says there is nowhere for a browser to connect. Held from the source as well as from a run by where_it_listens.rs, which reads every address the shipped tree names: loopback needs nothing written down, and everything else must be one the policy names, in the one file that decides the tier — and each tier must be named on both families, so a tier that lost one is red. That guard is also why the request half is symmetric: a Host naming a literal address is let through when this is offered to a network and a name is not, on either family, because what a rebinding attack rebinds is a name. C6-R14 is cited by no merged commit and cannot be: the change that made it true closed four requirements under trailers naming three of them, and a later commit citing it without advancing it is the claim the gate exists to refuse. It was put to an adversarial pass — the IPv6 wildcard replaced with a second IPv4 one in the policy, and an is_ipv4() filter added to the diagnostic — and went red four times. It landed in a8028cc.
No certificate it made for itself C6-R7 Self-signed TLS is not enabled, and there is nothing to enable: the crate that takes the socket carries nothing it could serve TLS with, which where_it_listens.rs reads from its manifest rather than from its code — a certificate turned on is a dependency first, and a dependency arriving is a line in a diff somebody adds on purpose. The reason is said to the operator instead of left implicit: a certificate this program made for itself is one a browser warns about, and an operator who learns to click past that warning has been taught something that costs them far more than plain text on a network they trust. So the transport is stated in words, and the words are not the same on a network as on one machine — on a network they say that anything between a device and this machine can read what passes over it including the password as it is typed in, which is the fact nobody would guess and the one C6-R6 exists for. Real certificates come from the Caddy overlay, which is a different row and a different repository.
What is actually listening, asked of the thing that is listening C6-R13 Every other artefact in this product that says where a service listens says what was intended: a compose file asks for an address, a manifest declares a tier, a test reads both. None of them is evidence. A mapping edited by hand and applied, an image whose defaults changed under an upgrade, a container started outside Compose — each is a service listening somewhere nothing on disk says it does, and a check that read the file would agree with the file and be wrong. So this asks the container engine what it published, which is the runtime’s own account of the sockets it is holding and the only account in reach that was not written by whoever wrote the intent. bindings.rs, in the network family, so a run can be narrowed to it. The engine’s port table arrives through the port every other container reading does — docker.rs gained Published, one entry per host address — and both families are held separately, so a service on 0.0.0.0 and one on :: are two readings of the same rule rather than one that covers whichever family was looked at. The rule itself is the stack’s own: a service the manifest calls loopback may answer this machine’s addresses and no others; one it calls lan may answer anything, because reaching it from a phone is the whole of what it is for; and one the manifest declares no tier for is passed over rather than guessed at, since a rule invented in the check would be a second opinion about a question the stack already answers. A wildcard is said in words as well as in numbers — 0.0.0.0, which is every interface this machine has — because neither wildcard is an address anything is at. Each service that is wrong is its own finding, so two are two things to put right. What it cannot see is written down rather than implied: a stack that publishes nothing is skipped rather than passed, an engine that will not answer is unverified and never a pass, and lemonfiber’s own surface is not a container — where that one listens is decided in one file and guarded from the shipped tree by where_it_listens.rs instead, which is a different kind of evidence and is named as one.
The household tier is reachable, and narrows through one setting C6-R2, C6-R17 The rule that nothing is published beyond loopback without a reason says nothing about whether the services whose whole purpose is being reachable actually are — a household service quietly pinned to loopback is a library nobody can watch, and it would pass every check on that side. So publishing.rs reads it the other way too: every service the embedded manifest calls lanjellyfin, seerr, calibre-web-automated, audiobookshelf, homepage, caddy — is published, and published through the one setting that narrows the household tier rather than at an address spelled out beside it. That is the honest half of the trade the stack makes: it has no way to learn which of a host’s addresses is the LAN one, because that varies by machine, changes with DHCP and is different again on a laptop that moves — so the default is every interface, the operator is told so plainly, and what they are given in exchange is a single knob that narrows all of them at once. A mapping that spelled an address out would be one that knob does not reach, and this is what makes that red rather than merely unfortunate. The knob has a name and the operator reads it where they set it (C6-R17): LAN_BIND is declared once in the stack’s own .env.example, with the tiers either side of it spelled out — which services are always on 127.0.0.1 and are not configurable there, and which are published on it and why — and the default is not left to be inferred. 0.0.0.0 is stated as every interface on this machine, with the narrower form written out beside it, and the two things worth knowing are said rather than implied: Docker’s port publishing steps around some host firewalls, so a rule the operator added may not apply here, and the tier assumes a network worth trusting, which a café or a shared flat is not. The setting is the stack’s, so the sentence lives where the value does rather than in a document beside it.
A rule you added does not decide, where it does not C6-R16 Docker’s publishing steps around some host firewalls, and the stack’s .env.example has said so for a while — to whoever opens that file. The requirement is that lemonfiber warns, and nothing did. It warns from the check that already asks the engine what it published, because that is where the fact is: a port answering beyond loopback on a machine running the engine directly is reached through forwarding rules the engine wrote ahead of the ones a person adds, so a rule written to close it is not what decides whether it answers. Nothing here reads a firewall — the rules belong to whichever of several tools the operator uses, and reading them would be a guess about which — so what is said is the arrangement rather than a claim about this machine, and it is said only where the arrangement holds. Docker Desktop puts the engine behind a virtual machine and forwards from a process the host firewall does see, so macOS, Windows and Desktop-on-Linux are not this case; a warning that fired everywhere would be one nobody could act on. A warning rather than a failure, because this is how publishing is meant to work and nothing is broken — what it costs is that a port somebody believes is shut may not be, so it names the ports, says what every interface means, and points at the one setting that does decide. Both halves were watched failing: removed, its own test goes red; made to fire on every platform, three other tests go red for a finding they did not ask for, so the scoping is load-bearing rather than decorative.
An exposure somebody agreed to is theirs, and still reported C6-R15 The diagnosis had been offering to stop reporting an exposed admin surface “if you meant to expose it, say so once” — and there was nowhere to say it. A remedy offering an action nobody can take is worse than no remedy: it reads as though the decision were available and sends the operator looking for a thing that is not there. LEMONFIBER_EXPOSED is where it is said, as a service and a reason — a name on its own records that somebody clicked past a warning and nothing about why, and whoever reads this next, in a year or in a support bundle, is served by the sentence rather than the name. It is the same floor the displayed-settings register is held to. An entry with no reason, or one too short to be one, is not an acknowledgement and is dropped, so the exposure it would have excused goes on being reported — failing that way round is the point, because a malformed acknowledgement that silenced the warning anyway would be the one arrangement worse than none. What agreeing changes is whose decision it is rather than what is true: the exposure stays on the report as a warning rather than a failure, the operator’s own words come back with it so a diagnosis sent on carries why, and saying so about one service says nothing about the next. It is displayed rather than withheld, because a record nobody can read back is not one and this is not a credential. Two plants, three reds: the reason requirement dropped, and the check made to ignore the record.
An exit code says which kind of wrong G1-R8 Five non-zero classes rather than a single failure: 1 a run that failed, 2 a request that was not usable, 3 a machine that was not ready, 4 services that never settled, 5 a value that would not validate — exit.rs. Which one a run ends in is decided from the error’s own code, and for the commands where the answer is a judgement rather than an error — doctor, seed, quality, upgrade, music — from the report, so a doctor that found something is told apart from a doctor that could not run. The whole table is documented for the reader, code by code and command by command, on the error-code page the docs site publishes.
The dashboard on a terminal that offers nothing G1-R9 No true colour, no chosen font, no minimum size worth naming. The only colour anywhere in the terminal surface is the log viewer’s severity styling, and it uses the two basic ANSI colours every terminal since the 1980s has — nothing indexed, nothing 24-bit. The dashboard itself distinguishes by DIM, an attribute rather than a colour. Nothing it draws needs a glyph beyond an em dash and a middle dot, so a terminal with no icon font renders it whole. Below ninety-six columns it drops from two columns to one and keeps all seven panels rather than hiding any, and at eight columns by four rows it still draws — both widths held by tests, and a third that pins every panel having somewhere to go at either. Two of the three are read back now rather than surveyed. modest_terminal.rs refuses any colour that is not one of the sixteen a terminal has before anything is negotiated, and any non-ASCII mark the screen writes that is not named with the reason a plain font draws it — the em dash and the middle dot, and nothing else. Escapes are resolved before the check, so a private-use codepoint spelled as an escape is caught as the glyph it is rather than passed over as the ASCII it is written with; the guard was shown missing exactly that before it was fixed. The boundary is the module trees that draw: every file naming the drawing library, plus every file beside it under the same module, which takes the log viewer’s notices — they write lines the viewer puts on the screen without importing anything to draw with — and leaves the report surface, which writes to a pipe as readily as to a terminal.
One stack, one run at a time, whichever surface asked G1-R12 Both halves, and the second was the one missing. Same state holds by construction: every web endpoint turns its request into one of the core’s own commands and hands it to the dispatcher the command line hands it to, so there is one answer rather than two that agree today. Serialised is a claim on a file — lifecycle.lock beside the settings, taken in one atomic call, given back on both ways out, and a refusal that names the run holding it and how long it has been there. Five operations claimed it and two did not. A switch stops some services and starts others, which is the operation the lock exists for, and it is reachable from either surface, so two switches or a switch against a start ran interleaved. A pull through the dispatcher claimed while the same request typed at a terminal went to the streaming path and did not, so the two surfaces disagreed about whether a pull is a lifecycle operation at all. Both claim now, and the claim sits in the command rather than in the helper setup shares — setup’s pull is followed immediately by a start that claims, and a first run has nothing to race. Each refusal is held by a test that was shown failing first: without the switch’s claim the run reached the container engine.
Setup is completable from the browser too G1-R14 The wizard was reachable from the command line and the terminal and from nowhere else — twelve actions, none of them setup, and no route to one. Setup is a command now — where the walk is, one answer, next, back, apply — dispatched through the same entry point every other command goes through, with five endpoints that translate and do nothing else; lemonfiber setup --status issues the read, so the command line offers it too. The answers between requests live in the progress file setup already writes rather than in the server or in the caller: nothing expires, a reload loses nothing, and a walk begun in a browser is one a terminal finishes — where a copy held in the server would die with the process, and one carried by the caller would put every gathered credential back on the wire on every call. No decision moved: which question comes next, which apply at all on this machine, what an answer may be and what the answers add up to are read off the wizard, and applying is the same resume the terminal reaches, at the same gate. A refusal is the problem envelope every other endpoint answers with, at the status that says whether the request could ever have worked. The report says what was decided and never what was entered: the plan’s values are withheld by the rule config show already uses, and a credential submitted as a command is recorded unproven, because nothing on that path tested it. Two things the terminal walk does are still terminal-only and neither blocks completing setup: proving a credential live, and choosing how to recover an interrupted apply — which is reported, so setup stays on offer. setup.rs.
The third surface drives the same logic and adds none of its own G1-R2 The console asks lemonfiber and draws the answer; it decides nothing. Every read leaves through asking.tsasked and watching are the only two ways anything in the app reaches the binary, and both hand the request to the generated client, which is built from the contract the server emits rather than written against it. The endpoints they reach turn the request into the command a person would type and give it to the dispatcher the command line gives it to, so a browser fetching a question and a script piping --json receive the same bytes (ARCH-R46, ARCH-R47). Two architecture rules keep it that way rather than leaving it to intent: one refuses an HTTP client anywhere under the surface, and the external-origin rule in guards.mjs refuses any address that is not this machine. What the app holds beside that is vocabulary rather than judgementwire.ts maps the server’s nine service states and eight gradings onto the interface’s five states and three tones, another module chooses a byte unit, another writes a span. No health is computed, no stall is classified, no service state is inferred, and every panel is handed what it draws. What is not covered: the write half is unexercised — nothing in the app posts an action yet, four of the five places the menu leads to draw a place-holding panel, and the household view does not exist. No gate makes a later screen read through the client either: a bare same-origin fetch naming a path would pass both rules above, since one names packages and the other reads for a scheme. And nothing compares the two surfaces’ renderings — the terminal dashboard maps the same nine states its own way, and the two agree because both read the same field, not because anything checks.
Every action reachable from every surface G1-R1 The terminal offered no action at all and could be asked no question the panels did not already answer: twenty of the twenty-six requests had a terminal form of none, the dashboard read six panels, the log viewer read, and the wizard was the only screen that changed anything — on a machine that has not been set up yet. All twenty-six requests reach all three surfaces now, with one exception that is argued rather than pending: ui on the web, because a surface cannot start itself. The dashboard acts on thirteen requests, answers eight reads, shows one on its panels and reaches one by handing the terminal over. Beside those it makes the seven writes that act on a request it already reaches as a read — the quality preset chosen, that choice re-asserted over a hand-edited file, the library fetched again at the bar in force, the diagnosis widened to the checks that disturb a running system, the repairs it found put right, a warning about a deliberate choice accepted, and the last repair put back. Five are the lifecycle actions the screen already showed state for — up, down, switch, restart, pull — each on a key of its own; six more are the writes that are not about what is running at all — seed, adopt, reset, backup, support, restore; the eight reads it answers as questions are version, doctor, forms, config, quality, household, trace and stuck — the last of which its panel had counted without ever naming an item the way a trace is asked for, and the second of which its panels had shown the raw material of without ever showing a verdict about any of it; and the last three are the ones that do not answer and stop — walkthrough, watch and ui. Both halves are the web’s. An action names one of the actions actions.rs offers and a question names the path reads.rs serves a read at, and each goes through the same translation a browser’s request goes through — so the terminal reaches the command a browser reaches and cannot grow one no other surface has. What each refuses is asked of those tables rather than written down a second time: a stack declaring no forms refuses a switch in the same words on both surfaces, a trace with nothing typed is refused in the sentence a browser is refused with, a setting and a member named as nothing are refused in theirs, and a restore named nothing is too. Nothing happens on one keypress, and a large consequence is read before it is agreed to. A key opens the list of what the action can be given; taking one puts the question; only an explicit yes goes ahead. Three of the six new writes have a half that reports and changes nothing — what a reset would revert, what a bundle would hold, what an archive would overwrite — and those three send that half first, so the answer is on the screen and the question is the line under it rather than a sentence in front of it; an effect somebody reads after agreeing is not one they agreed to. Which three is asked of the table saying which actions carry the operator’s agreement, not decided again. That table says which actions carry an agreement and not which of them have a half that only reports, and the three quality writes are where the two come apart — which is why they are behind a key of their own rather than on that list. quality-upgrade states what fetching the library again would cost, per media type, and triggers nothing. quality-set unconfirmed records the choice, holding it only where this host would have to transcode the result in software, which is the one cost its agreement is for — so the choice is made off a list of the bars carrying what each means and costs, reached through the media it applies to, which is the step in front of it, and where the core holds one it answers with its own caution and a second question sits under that. quality-reapply has no such half at all: the core’s rehearsal of it is behind --dry-run, which is a property of a run rather than of a request, so its question is put with nothing above it rather than with a preamble invented for symmetry. A restore takes the name a backup was written under and never a path: the same translation a browser’s goes through carries it as a name, and resolving it beneath the backups directory is the core’s. A bundle is asked what it is to hold: how much of each service’s log to take is typed on a line of its own and what becomes of media filenames is taken off a list of the two the command carries, and both are said in the question above the yes. The careful answers are still where an operator who presses enter twice lands — an empty line is the ordinary window, and the list opens on filenames replaced — but being the right default is not the same as being the only answer available, which is what a surface that never asks makes it. The agreement the command names is carried now too: the yes had been spent on write the file while the field the core reads for consent went out false on every bundle that screen ever produced, which changed nothing about the file precisely because that screen names no setting to show as it is. Nothing revealed is the one thing about a bundle still not offered there, and it is recorded as an exception rather than a gap — a way past the withholding list on that surface would be a capability no other surface has, on the surface least likely to be sitting behind a login, and the alternatives of building it behind the browser’s own agreement or taking it off the browser too were both put and both declined. A guard beside that screen’s own list holds every bundle it can send to naming no setting at all, which is what makes the agreement beside it safe to carry. A screen that can list can name one thing in the list. The four questions that showed everything and could narrow to nothing were one gap rather than four: a setting, one member of the household, what starting one form would come to, and following one of the items whose download has stopped. Two are named by typing, on the line a trace was already typed on, with which of the read’s own arguments the word fills said beside the question rather than built into the line. Two are named by taking one off a listing the screen asks that same read for first — its own command given nothing, so no listing is written down twice — because a form and a stuck item are already written down where the screen can read them, where a setting or a member could only be picked off the very answer the narrowing exists to avoid asking for. Following a stuck item asks a second read by the entry’s own title, which is the arrangement the web’s stuck list already had, and waiting for a listing is a different wait from waiting for the answer about the one taken. Naming nothing at either line is refused where a query string carrying nothing is refused: /api/config?key= had reached the core as a setting to look for and come back as a listing of none, and /api/requests?member= as a household that has asked for nothing — both of them true-looking answers to a question nobody asked. Five keys carry sixteen requests and six more offers. Twenty-six will not fit on one row of a footer or in anybody’s memory, so one key opens the questions, one opens the rest of the errands, one opens the two that keep going, one opens the three ways of changing the quality the questions already report, and one opens the two ways of answering what a diagnosis found — each the same list, movement and enter an action’s own subjects are chosen with. The second says only more, which is the most it can honestly say about a wiring, a capture, a bundle and a revert at once. A list of the stack’s own forms names several of them. The command line takes a list of forms and a browser sends one whole; this screen’s list took one, and that was the last thing four of the five lifecycle actions and the guard were short of. A row is marked with the space bar and the marked rows are what enter takes, and where nothing is marked enter takes the row under the cursor — so an operator who never presses the new key has the screen they had before it existed. The line under the list says which of those two enter would do and changes when that changes, because the one moment the choice is ambiguous is the moment the screen can resolve it. The whole stack is instead of naming forms rather than one more of them, so marking it takes the marks off the forms and marking a form takes the mark off it; naming nothing at all is not a state the screen reaches, since the cursor is always on a row, so an empty list of forms arrives only from the row that says everything — offered only where the translation carries one, which keeps the two actions that read an empty list as everything and the three that refuse it as far apart here as they are in that table. Several go through the same translation as one list, so what is sent is the command a browser sends, and the question in front of it names every form it covers rather than counting them. The guard is chosen off the same list by the same movement, because one list behaving two ways depending on which key opened it is what a shared movement exists to prevent. The last three did not share a home. A walk and a guard run for minutes and are watched rather than waited for, so they are behind a key that says what they have in common and nothing else; the web surface is not work sent to the stack at all — it reaches no action and no read, because no other surface has one — so it is a key beside the one that quits, and it ends the screen rather than sharing it, because its address, its warning and its token are eleven lines somebody has to read and copy. A walk’s words are the core’s own. Each step arrives through the same narrator port the web’s stream is fed from and is drawn by the renderer a shell reaches for the same step, so this surface carries one account of a walk rather than a second copy of its prose; what it came to goes under the steps that were watched, which is the order a shell shows them in. Only what has no ending of its own is offered an end, and which one that is is asked of the table that says which work the web holds on a lease rather than decided again: a guard is let go by the interruption a shell would use, which on a screen in raw mode is a keypress, and the abort it performs is the same one releasing the name performs. Leaving ends neither of them, and the line on the way out says which will finish and which will keep going until the data location is lost. A long action reports through the screen it interrupted and an answer is read rather than glanced at: the web answers an action that reaches the container engine with a job’s name because a request cannot be held open for minutes, while a terminal needs no such indirection because the dashboard is the report, and an answer too long for the box is moved through rather than cut, with what is off each end counted. The settings are a read like the others and withheld like the others — the screen asks for config show and never opens the file, which a guard beside the withholding list refuses to let it start doing. The terminal column is machine-checked now. acting/ is private to the binary, so reaching.rs publishes a projection of what the screen offers — each request, and the action or read it goes through — held to acting/‘s own lists by tests beside them and read by surface_parity.rs, which fails on a row claiming a screen that offers no such thing and on an offer no row accounts for. Every decision — which key, which subject, which question, which errand, the question in front of an action and what is said about every answer — lives in acting/ under the coverage gate, and not in terminal.rs, which is excluded from it (#373). The three that run for minutes are reachable now. A guard, a walkthrough and a followed log were the last requests a browser could not make at all, and they were one shape rather than three: work that outlives its request and produces lines rather than a value. Each is answered with a job’s name and says what it has to say on the stream a browser already holds — a walk’s steps whole rather than rendered, so the words stay the core’s and no second copy of them lives on this surface; a service’s own lines under the kind every other envelope’s event name comes from, so a browser that is not following never sees one. What building them took is the half of the job name nobody had needed yet: a name has to be givable back. A terminal ends a long command by interrupting it and a browser has nothing to interrupt with, so releasing the name ends the work where a shell’s own interruption would have left it, and what the container engine was already asked to do goes on. A guard is the one command with no ending of its own, so it is also the one held on a lease — asking what became of it renews it, and one nobody has asked about across two sweeps of half an hour is let go rather than left polling a drive until the process stops. Repair reaches a browser, and the consent does not get weaker on the way. The diagnosis was served and putting it right was not: --fix, --yes, --fix-disruptive, --undo and --accept had no web form at all. The obstacle was never the plumbing — it was that a terminal holds the question open in the process that asks it, so the run that acts is the run that looked, and HTTP has nothing of the kind. So consent became data: an offer names itself by every word an operator reads before agreeing — what each repair would do, what else changes if it does, whether it can be taken back, and the order they were offered in — and the answer sends that name back with it. The run that acts looks again, recomputes the name, and refuses to spend consent on an offer that has moved on, having carried nothing out and having recorded no decline nobody made. Nothing is held between the two requests, so a browser tab closed halfway through leaves nothing half-consented, and no offer sits anywhere waiting to be redeemed against a diagnosis that is no longer true. The three shapes are the three the command line spells and no fourth: without the yes it is the offer and changes nothing; with the yes and the offer it was read in it is consent to that offer; with the yes alone it is --yes, a decision taken before there was anything to read, which is not the same as skipping being told. Widening to the checks that disturb a running system is carried apart from the consent, because agreeing to a repair and agreeing to be disturbed are two decisions. undo is its own action and carries no subject at all — which repair was last and what reversing it takes are the core’s to decide — so one told which repair to reverse is refused rather than obliged. The diagnosis that disturbs is asked for on its own now, and the argument that had kept it out is what shaped it. It ran in a browser only as a side effect of repairing or of answering a warning, because every endpoint serving a diagnosis is a GET and a read that disturbed something would not be a read — which is true, and is a fact about the door rather than about the request. So diagnose is the same request at the door changes are asked for: it reaches Command::Doctor with the widening set, exactly as doctor --disruptive does, and the fork is the surface’s rather than the core’s — the checks are the same checks reporting a real verdict where an ordinary run reports them unverified, so unlike Command::Halt beside Down there was no second command to add. The widening is required rather than defaulted, since a request without it would be /api/checks under a second name; the narrowing is offered beside it, because both disturbing checks name what to run in what they tell the operator — the live release search says --only services.releases --disruptive outright — and a browser that could only ask for all of them would have to drop the tunnel to spend one indexer search. It is answered with a job’s name, which is what a run bounded by how long it may hold the tunnel away has to be answered with, and the read beside it goes on refusing the word so the two cannot become two ways to ask one thing. And the screen shows the diagnosis now, rather than the facts it reads. Two panels carried storage and VPN readings a diagnosis reads too — how much room is left, whether imports link, where traffic leaves from and whether the client’s traffic is inside the tunnel — and a fact is not a verdict: a pass, a warning and a check that could not be established all render as the same number in a panel, and no panel carries a remedy, which is the half worth having. So doctor moved off the list of what the panels show and onto the list of what the screen can be asked, at the same /api/checks a browser reads, whole or narrowed to one family or to one check by the id its finding carries — the last of which is what /api/storage is, asked for by name. The panels stay, because a gather every second and a diagnosis somebody asked for are two things rather than two renderings of one. The checks that disturb are offered under that answer, on no list and behind no key: they are not a question, since every question here goes through the table of reads and a read that disturbed something would not be a read; they are not an errand, since that list’s rule is that an unconfirmed run says what it would do and this action has none — the widening is required, because a diagnosis that disturbs nothing is the read already served; and one action does not earn a letter on a screen that refused one per errand. What it is is the second half of an answer already on the screen, and that answer is the account the question sits under — an ordinary run reports both disturbing checks unverified and each finding says to run that one, so this is #401’s rule arriving where the account is a report rather than a rehearsal. The narrowing comes with it: the word that narrowed the reading narrows the widening, so following either of those findings does not take the tunnel away in order to spend one indexer search, and no list of the families of checks is written down on this screen at all. And putting it right closes the row. repair is asked for unconfirmed first, and that run is the offer, so the account the question sits under is not a rehearsal of it; the repairs are a list that takes several, marked one at a time, because this is the one action on any surface that shows the operator something and then acts on what they answered — the sole name under TAKES_CONSENT. The yes names the offer those repairs were read in, and the core recomputes that name from a fresh look before it carries anything out, so an answer cannot be spent on an offer that has moved on: the property a terminal gets for nothing by holding the question open in one process is bought here the way a browser buys it, because this screen sends two requests too. accept is answered off the run that raised the warning — only something a run warns about can be accepted, so the warnings are asked for and offered as a list to take one of, and a failure, not being a choice, is not on it. --undo is an errand instead: it reads no offer, answers no warning and names no subject, so its yes is the whole of the agreement, which is the errands’ rule and not the other list’s, and it sits beside the archive put back with the narrower reversal first. Two flags are spelled differently here rather than left out — --yes is the standing consent taken before there was an offer to read, and a screen that has just shown the offer sends the consent that names it, which carries the check the standing form cannot make; --fix-disruptive is the same widening --disruptive asks for, and the core settles which half of a repair it belongs to — the half that acts, an offer asked to include those checks being refused rather than widened, over checks that turn up no repair to offer — so the screen reaches that widening on the request it is about. Three requests were partial on the web for one reason: the command line did the work around a Command rather than through one. up --service never reached a command at all, so a browser could start a whole form or nothing; the answer to the question the parity page left open — whether starting named services is its own request, the way Halt is not Down — is yes, and it is Command::Start, because bringing a form up creates what its closure holds and starting named services starts the ones named, which is the pair Compose spells up and start. down --wait was a loop the command line ran around a queue reading, so a browser could only stop now; the wait is a field on Command::Down and the loop is inside the teardown, which is what lets a request that cannot be held open for an hour ask for it and be answered with a job’s name. The question in front of it stays on the command line, because --yes answers a prompt no machine-readable run is put. setup proved a credential and offered three ways out of a half-written apply only from a terminal; both are commands now — an answer records what a live test established rather than what a caller claimed and carries back what the service said, and the ways out are SetupAction::Recover, put after the report has named what the interrupted apply had already written. The web column is finished, and this is still partial. Twenty-five of the twenty-six requests reach a browser whole, ui is the one exception the table permits anywhere, and none is left half-reachable — the diagnosis that disturbs was the last of them, so no gap remains in that column at all. Every request is reachable from every surface now, and none is reachable only in part on either: surface-parity.md has no row saying partial left, and the guard reads that back in both columns — three figures down the terminal’s, because it has an exception of its own to count. A partial is a gap to close rather than a footnote. ui on the web is the only exception this table permits anywhere, so a request that loses an argument on a surface leaves this requirement open however small the argument is — on the web, and on the terminal, where every row reading dashboard, partial is a gap on the same terms rather than a smaller version of the request. The last four narrowings closed together, and each already had a shape on that screen. A quality choice is narrowed to one media type by taking it off the step in front of the bars — the whole library, series, film, or music — and music comes back as three audio formats where the rest come back as four presets, because every bar of both kinds is put to the translation for each media in turn and only what reaches a command is offered, so the fork --for music takes is read rather than written down again. A trace is narrowed to one season by a second line under the first, the words already given staying on the screen above it, and a season that is not a number is refused where a query string carrying one is refused — the line takes digits and nothing else, so what is turned away is a keystroke rather than a request. A restore accepts a re-point where the archive’s own listing says one is needed, which is the same run that says what would be overwritten, so the question under that account is the re-point rather than a second agreement given blind. And a bundle chooses what it holds. Which of the four is typed and which is taken was not a preference: a media type and what happens to filenames are fixed sets compiled into this binary, and a season and a log window have no list to be taken off — a season’s would be the very trace the narrowing exists to avoid asking for. The web’s own last two closed together: a bundle is handed to a browser where --out would have named a path, and the backups a restore may name are listed to it where a shell reads the directory itself — both of them the same shortcoming, that a browser has no filesystem. Seven of the terminal’s closed in two goes, each time because several rows were one gap wearing several names rather than several gaps. First the lists offering the stack’s own forms took one form where the command line takes a list, and switch, pull and watch were short of nothing else. Then up, down, restart and backup were each short of the services --service names — and the names were a gather the lists did not have, sitting on the panel behind them all along. The services panel is refreshed every second and is taken from the manifest rather than from what happens to be running, so a service that has never started is on it; the screen keeps what each gather leaves and builds the list from that, which is why the argument is picked. Typing it was the only alternative and a typed service name is a name nothing checked before the work ran — while the read that would have checked one reaches the container engine, and this screen makes no such read between a keypress and the frame after it. Two of the four reach a different command when services are named — Command::Start beside Command::Up, Command::Halt beside Command::Down — and the fork costs the screen no second flow, because the screen assembles no command: every row goes through the same translation a browser’s request goes through. What does differ is one name against a list of them, which is that same table’s answer too — an archive records one scope, so the capture’s rows carry no mark. Naming no service is going on with what was already named, which is checked against that table rather than assumed to match the list of forms beside it, where three actions refuse an empty one. The key that hands the terminal to the web surface no longer starts it at its defaults: the port, whether a browser is opened, and the directory the interface is served from are chosen under its question, before the yes, because afterwards there is no screen left to choose on — offered on enter rather than on a key of their own, this screen having spent enough letters already. The objection on that row was that a port typed at a screen is a number nothing had checked was free, and nothing can check that and still be true a moment later — so the check is the bind the command line already makes inside the request, and what the screen answers is whether the word is a port at all, said beside the request where every surface reads the same refusal. The tick is taken now, and it was withheld until both columns were finished rather than taken on the one a slice happened to close. Every request the command line accepts is reachable from a browser and from a screen, and reachable whole. Two exceptions stand, one in each column, and each was argued rather than left: ui on the web, because a surface cannot start itself, and the credential reveal on the terminal, because a way past the withholding list there would be a capability no other surface has, on the surface least likely to be sitting behind a login — offered the alternatives of building it behind the browser’s own agreement or taking it off the browser too, neither survived. Both are spelled in the table as exceptions and counted apart from the gaps, which is the distinction this requirement exists to keep.
The web surface is swept at the level it owes, in every rendering a reader arrives in G3-R3 a11y.mjs builds the component gallery, serves it, and drives every one of its 136 entries in a real browser, reading each with the tags that spell out the level once per rendering: paper, ink, the system dark path no toggle sets, a reader asking the system for more contrast, and one whose system replaces every colour outright. A violation in any of the five fails the run, and that check is required on the branch, so this is a merge gate and not a report. Beside it, one pass per entry numbers every place focus can land and tabs once more than there are of them, failing where fewer are reached than exist; a roving tabindex is left out of the count, so a group meant to be entered once and moved through by arrow is not read as somewhere focus cannot leave. Since the console landed the sweep reads assembled pages rather than only isolated parts — the shell with its wordmark, menu and a live dashboard inside it, the dashboard in seven states, and the screen that asks for the key — and that is what found three reflow faults no component could show on its own: a place-holding bar reading its width as a floor rather than a ceiling, a figure and its unit unable to break, and a panel floored at its widest contents. Focus is drawn once, globally, and takes the system’s own highlight where the palette has been replaced; nothing in the surface sets outline: none. What is not covered: a machine finds a fraction of what the level asks. It cannot judge whether an alternative says the right thing, whether a focus order is meaningful, or whether an accessible name matches the label a reader sees, and no screen reader has been driven over any of it. The level’s focus criteria have no automated rule at all. Where the system has replaced the palette, the browser has already replaced the colours that would be measured, so that rendering is read for structure rather than for contrast. And the sweep reads gallery entries: nothing requires a screen to have one, and the router and the switch above it have none.
A reader who asks for stillness gets it, and something checks every screen G3-R5 Two things in the whole surface move, and both stop. Switch.svelte slides its knob over fifteen hundredths of a second and drops the transition outright under the preference; Skeleton.svelte fades a place-holding bar over a second and a half, and declares that animation only where the reader has expressed no preference against motion — opt-in rather than opt-out, so the quiet path is the one that needs no exception. Neither rests on being remembered. a11y.mjs opens each of the surface’s 136 gallery entries, walks the rendered tree — elements and the two pseudo-elements, since the one transition in this interface is on the pseudo-element a switch’s knob is drawn as, and a sweep reading only elements would find nothing and call that clean — reads every declared animation and transition period, then sets the preference and reads them again, failing on anything still not at zero. The walk is scoped to the entry’s own root, so the gallery’s hidden loading spinner is not reported against all 136. The check is required on the branch, so a component that moved without answering the preference could not merge, including one written for a screen that does not exist yet. What is not covered: this reads computed style, so movement no stylesheet declares is invisible to it — a script driving the animation API or swapping classes on a timer, an autoplaying video, an animated image, smooth scrolling, or a transition applied from code. And it reads gallery entries: a screen shipped without one is never opened.
Nothing repeats fast enough to flash, in either surface G3-R6 a11y.mjs reads every declared animation in every gallery entry and fails any that is set to repeat for ever with a cycle shorter than a third of a second — three flashes a second is where the guidance draws the line, so a shorter cycle is one. It reads the text-decoration line for a blink in the same pass, which is the other way a stylesheet can ask for one. Exactly one animation in the surface repeats at all: the place-holding bar’s fade, at a second and a half, declared only where the reader has expressed no preference against motion. The terminal surface carries none either — the blink attributes appear in no file in the workspace, and the only styling the log viewer applies is a dim modifier and the two basic colours. The check is required on the branch, so the browser half holds for anything built later. What is not covered: the check reads a shape — an animation a stylesheet declares — and says nothing about what a flash can otherwise be made of: a script swapping classes on a timer, a video, an animated image, or a colour driven from data. It reads neither area nor luminance, so the threshold that exempts a small enough flashing region is neither applied nor needed, since nothing here is allowed to repeat that fast at any size. And the terminal half rests on there being no blink attribute anywhere rather than on a guard that would refuse one: modest_terminal.rs holds the screen to sixteen colours and to glyphs a plain font can draw, and reads no modifier at all.
The type follows the reader’s own size, and the palette answers the system G3-R13 Two halves, each with something holding it rather than something intending it. Text sizing: every measure in the interface is stated relative to the reader’s own size, and guards.mjs keeps it that way — it parses each component’s style block and fails any declaration stating a length in absolute units, naming four exceptions and only four: a drawn line of two pixels or fewer is a hairline at every type size, a shadow and a stroke paint rather than measure, a media feature has nowhere else to take a value from, and a bare zero carries no unit at all. A radius is deliberately not among them, because a radius is a measure like any other. The scale in app.css is relative throughout and takes its steps from the brand package’s own size tokens, which are relative as well, so the chain from a panel’s caption up to the root holds; and the page sets neither a maximum scale nor a bar on scaling. High contrast: a reader asking the system for more contrast is asking the quiet steps to stop being quiet, and the stylesheet answers by collapsing the scale toward the text colour rather than redrawing it, so every word keeps its place and only its weight changes. A system that replaces every colour outright is answered differently: the severity tints step aside rather than claim a distinction the system has taken away, and the mark beside them draws a different shape for each state — a shape survives. The focus ring takes the system’s own highlight there. Both are renderings in the sweep’s theme list, on a required check. What is not covered: the absolute-length guard reads component style blocks and nothing else — the root stylesheet is not parsed by it, the brand package is not parsed, and a length written into a style attribute or set from code is not read. Nothing asserts that either media block changes anything: the sweep says only that no violation is found in those renderings, and where the system has replaced the palette the browser has already replaced the colours that would be measured. And no pass draws the surface at an enlarged text size — the narrow pass measures a 320-pixel window at the reader’s default, which is the reflow question rather than this one.
A bare run asks the screen, and says the whole of it G1-R3 A bare invocation decides between the dashboard and guidance, and it asked whether standing input was a terminal. The dashboard draws to standard output, and the two streams are separately redirected — so lemonfiber > out.txt left a keyboard attached and no screen, took the dashboard branch, wrote escape sequences into the file and held the run open waiting for a keypress whose prompt nobody could see. This feature’s own edge-case table names that case exactly: with output piped, print help and never open it. The screen is its own question now, asked of every surface rather than defaulted to the other, so a surface that has not thought about the difference cannot quietly answer for both. The other half was the help itself: a run with nowhere to draw got three lines, one of which said where to find it, and a pipe, a cron line or a CI step cannot go and ask a second time. The commands are listed now and the line pointing at them is gone; cli::help() sits beside the parser it describes, so the one place that prints it is not also the place that knows how.
A repair asks nobody who is not there G1-R4, G1-R13 doctor --fix chose its stance from two flags and never asked whether anyone was there to answer. A run with --yes acted and a run with --json reported, and everything else was asked — including a run from a script, where the offer reached a terminal nobody was at and the read after it blocked on input that never came, once per repair offered, with the run appearing to hang. It is refused now, naming the two flags that would have worked, the way setup already refuses a non-interactive run missing an answer (G1-R13). Whether anyone is present is the answering side’s own question, so Answers asks it, defaulting to yes — everything that answers in a test has an answer ready, and the one implementation that can find nobody there is the one reading a real terminal, which keeps that read in the single file an architecture test requires it to live in. The guard that should have caught this listed the three files a question travels through and repair was a fourth; it is on the list now, and its own claim that a question is only ever put where standing input is a terminal is true again. The fake in the test records being asked rather than refusing to be, so what is asserted is that the question was never put — the claim itself — rather than that the run ended badly, which a dozen other faults would also produce.
A narrow terminal is a terminal, not a smaller one G3-R8 The dashboard already reflowed; the log viewer clipped. The service column was padded to the widest name seen so far in the session — no cap, no reference to the terminal — and the paragraph beside it carried no wrap, so at sixty columns a twenty-one character name left thirty-eight for the line and the rest was never drawn. The name is never shortened; the width decides where it goes: beside the line while the column and forty columns of line both fit, on a row of its own where they do not, forty being the dashboard’s own figure for a column of text. Shortening was refused on the requirement next door — G3-R10 forbids a truncation that changes a value’s meaning, and this column is the worked example in its own row, having once cut at twelve. A wrapped line is still one line: paging counts admitted lines, so wrapping changes how many rows one takes and never what a keypress moves by, and a line is drawn whole or left out, since a wrapped row without the name above it belongs to whichever name is. Paragraph::wrap could not do it — the rows a line needs have to be known before the number of lines is chosen, or the paragraph drops the ones at the bottom, which on a tail is the newest. Three more rows of the same screen were cut and are not: the keys, ninety-two characters on one row, ended at the export key at sixty columns, so the one that quits was not on the screen an operator most needs a way off; the standing row lost the account of what the screen gave up, which is said at its end, turning a stated trade into a silent one; and the title, which cannot be given a second row, now names as many services as fit whole and counts the rest, keeping the unseen count whatever else goes. Wrapping moved to text beside plain, where an overrun is how a caller says whether its width is a preference a terminal will re-wrap or a wall a grid of cells will not. Eight guards, all shown failing on the code as it was; the small-size test that asserted only that the screen drew without panicking — which is why this survived — was replaced rather than left beside them.
Text this product did not write cannot drive the terminal it is shown on G3-R15 Three quarters of this was already built and the last quarter was held by a sentence. plain removes what a terminal obeys — the C0 range and delete, the C1 range some emulators still act on, the two line separators, the bidirectional overrides that draw gpj.exe as exe.jpg, and the zero-widths that draw nothing — while leaving every mark that spells somebody’s language, since a release name in Japanese is a release name. It is applied where the text goes out rather than where it is built: a report makes each line plain as it goes in, the dashboard’s values reach the screen through the one function that fits them, and everything printed for a person passes say::rendered. The parser’s door had none of it. It printed what it was handed, on the written grounds that serialising had already escaped every control character — which is true of exactly the range below a space. Above it serde_json carries the character raw, and that is asserted here against the serialiser itself rather than restated, because a sentence about a library that nothing checks is how this survived: U+009B, U+2028, U+202E, U+200B and U+FEFF all arrive intact. So --json was the one way out a release title could still reach a terminal through whole, and it is the way out a script uses, which is where LC_ALL=C is set and where nobody is watching the screen. Escaped rather than made plain, which is the difference between the two doors: a script asked for a value it could match against what the service holds, and a name with a character taken out of it no longer matches — a \uXXXX in its place is the same string to anything that reads JSON and six ASCII characters to the terminal the document is printed to. A round-trip test parses what goes out and asserts the name that comes back is the one that was sent. Tab, line feed and carriage return are deliberately left where they are: inside a string they already arrive written out, and between two tokens they are a document’s layout, which an escape would corrupt rather than disarm. Nothing serialises that way today, so it is stated rather than left for whoever adds pretty-printing. Two guards, and the second is the one that lasts. Every code point below the astral planes is asked of both doors and they must answer the same, so a character added to one is answered on the other rather than leaving the quieter door open. And every print statement in the funnel must carry one treatment or the other — which catches the third door somebody adds later, printing directly because that is what the two beside it appear to do; it counts the doors it found before judging them, since a guard reading a renamed file would otherwise pass while watching nothing. What is not covered: escaping preserves the character, so a script that decodes the document and echoes the value to a terminal has it back — that is the trade this door is built on, and the alternative loses the value the script asked for. The API’s own JSON over HTTP is not treated: a browser is not a terminal, and a client printing a response to one is doing the printing.
Nothing reports on you, and something notices if it starts to G8-R1, G8-R2, G8-R11 nothing_reports_on_you.rs, deny.toml. All three were true already and none of them was held, which is the whole difference this row records: an absence stays true by nobody having added anything, and one commit undoes it. What holds them now is three sweeps over corpora that grow when somebody adds something. The resolved dependency graph is read from Cargo.lock and matched against fourteen stems of crate families that exist to report somewhere — the graph rather than the manifests, because a collector arrives as somebody else’s dependency at least as readily as one of ours, and a direct-dependency check would pass on a transitive one while reading as though it had looked. The same stems are cargo-deny’s ban list, which stood at deny = [] under a comment saying dependencies introducing telemetry were banned: a gate reading an empty list, checking nothing, on every pull request. The two are held to each other rather than one deriving from the other, because they run on different paths — this sweep runs in the test suite and matches a family by its prefix; cargo deny runs on a dependency bump nobody opened and matches one name at a time. Every host beyond this machine that the shipped source names is held to a table saying whether lemonfiber asks it for anything and what for: six, three asked and three printed for a person to read. Both directions, so a host the source stopped naming fails as loudly as one it started naming. And every reach of the one port that produces unpredictable bytes is held to an entry saying what the bytes become and whether they outlive the run — three, none of them about the installation: two are passwords for services on this machine, and the third is a per-bundle salt whose entire purpose is that it is not the same in the next bundle, which is the property an identifier is made of. Each sweep asserts what it is reading before what it found. A guard whose subject can vanish silently is the defect this repository keeps finding, so a stem that matches nothing, a host on the list the source no longer names, and a corpus too small to be the right tree are each their own failure.
A bundle, a backup and a log stay on the machine that made them G8-R6 nothing_leaves_on_its_own.rs. The word the requirement turns on is automatically, and it was met already: an operator who asks for a support bundle and carries it somewhere has done exactly what it is for, and the browser handed one over the loopback connection it already holds is that same operator with a different keyboard in front of them. What was missing is the thing that would notice a path from producing one of these to a transport, taken without anybody asking. Nine module trees make one — the tar, what a capture holds, the collection and redaction and scan a bundle is made of, what a service said, and the four errands over them — and none may name either port or the crate that implements them. Trees rather than files, because a concern here is a declaration and a directory beside it and a name matching only the file stops watching the moment the directory grows. It fails three ways: a tree that matches no file (something moved), a transport name that matches nothing anywhere (something was renamed and the guard did not follow), and a tree that holds the means. Where a bundle can go is matched exhaustively over Destination rather than searched for, which is the one claim here that cannot be evaded by wording: a fourth destination stops the guard compiling, and whoever adds it says what it is before anything runs.
The two references are generated, and a stale one fails the build ARCH-R68, ARCH-R69 The same arrangement the contract above has, twice more, and it was built and left unrecorded. reference/commands.md is rendered from the declarations the command line parses with — clap’s own tree, walked into every subcommand, less the help clap adds itself, which this product does not declare. reference/error-codes.md is rendered from the codes the crates declare, and there is nothing to enumerate at run time because a code is declared beside the thing that raises it: codes/scan.rs reads the declarations out of the source the way the compiler does, telling code from a string from a comment and counting braces to know which half of a file ships. just reference and just codes write them; the comparison is a test, so a command renamed or a code added without regenerating fails the build rather than the program that emits it. Three things make that comparison worth having. clap renders styled help too, and an artefact carrying escape sequences would compare equal to itself while being unreadable everywhere it is shown, so one test asserts there is no escape in it; wrap_help is not enabled, so nothing asks the terminal how wide it is and the same commit renders the same artefact on every machine, which is what keeps this from failing by the weather. And rendering only the root command would also match itself, so completeness is named rather than assumed — a section per subcommand and the ones beneath them. ARCH-R69 asks for one thing more than its neighbour: a failure when a declaration cannot be enumerated. That is a test of its own rather than a branch of the comparison, because the two fail for opposite reasons — an artefact that disagrees is regenerated, and a read that could not finish is a declaration to write differently. The reader refuses to guess: a code whose name is not a literal, and a file whose braces do not balance by its last line, are reported rather than quietly left out, which is the one way an inventory taken from source could be wrong while agreeing with itself. Beside them, the two directions a scan can go wrong are pinned by name — a code that exists only inside tests is not one lemonfiber can raise, and AAC-320, MP3-320 and UTF-8 are text shaped like a code and are not one. Sorted by family and then by number, so a family reaching ten entries extends the artefact instead of reshuffling it.
The household has one front door, and nothing stands in for it G5-R1, G5-R2, G5-R3, G5-R4, G5-R5 The stack publishes six things to the local network and only one of them is somewhere to begin, so the question has one answer and it is derived once — in door.rs — rather than decided again by each surface that shows it. The request service where this stack has one, the library where it has none, and nothing at all where it has neither: that last is an answer rather than a failure, and it is said as one. Which service it is comes from what the stack declares, not from what happens to be up: a request service that is not running is a front door that is down, and answering “the library, then” would hand somebody a place they cannot ask for anything from without ever saying that is what happened — so a door that is not answering reports as exactly that, beside a sentence saying nothing here is a stand-in for it. The two that can be a door are told apart by the shape of their API rather than by their name, so a fork shipping a different request service under the same shape resolves the same way; the four that speak no API lemonfiber knows are a register of decisions somebody made and can be reviewed on, and a service nobody wrote down is offered to nobody — the posture config::display takes toward a setting nobody vouched for. The index over every service is named and refused by name rather than quietly left out: what the household can reach that is not the door is carried on the answer with the reason it is not, because the decision is the useful part and a reader shown one name has only been given a result. The answer reaches all three surfaces the way G1-R1 asks: lemonfiber front-door, GET /api/front-door (read/door.rs), and the list one key opens on the dashboard.
The front door’s address, read now rather than remembered G5-R8, G5-R9, G5-R11 Nothing in this workspace read where this machine is at all: the stack’s household tier publishes on LAN_BIND, which defaults to every interface, and the household’s own links default to localhost — so what a phone or a television would be sent to had never been worked out. It is now, from two things and no guess. What the machine calls itself comes back through a port of its own (network.rs), since a name differs on every machine the tests run on and one written into a test would pass where it was written and nowhere else; the adapter asks hostname through the process port every other program goes through, so there stays one place in this workspace that spawns anything. The name is preferred where there is one to prefer (G5-R8): a bookmark on a name survives the router handing out a different lease, and one on a number does not — a household member whose bookmark quietly stopped working does not debug it, they ask the operator or they stop using the thing. It is preferred only where the platform answers to it out of a clean install: macOS and Windows each ship a responder, and on Linux it is a separate package many hosts do not run, so a name offered there could resolve nowhere — which is worse than a number, because it fails without looking wrong. The name used is the one the responder answers to, which is not always the one the machine reports. A machine says its name with whatever suffix came with the lease, and a router that hands out a domain does not necessarily answer for it: on a common home router hostname reads machine.fritz.box, which resolves nowhere, while machine.local answers — so the domain is dropped rather than kept, and a name already in the responder’s form gains no second suffix. Measured rather than reasoned about, on the address the product itself printed: .fritz.box refused the connection outright and .local answered. Nothing is lost by dropping it, because an operator whose network really does resolve a qualified name writes it down, and a recorded name is taken at its word. Where there is no name to use, the address is the one the operator recorded for the household’s own links, which is now a setting this product reads rather than only describes; a number carries the note that routers hand out different ones (G5-R9), said in the core so all three surfaces say the same sentence, and a name they wrote down is taken at their word. Where there is neither, there is no address rather than one built from a default — an address invented from a default is one somebody sends on — and the answer says what to set so there is one. A change is noticed by nothing being remembered (G5-R11): every read builds the answer from what the machine says about itself at that moment, so there is no cache to invalidate, no schedule to be stale between and no freshness to claim, and the cost is bounded because the front door is asked rather than watched. A fake that answers one way and then another drives that through the whole read.
What to do when it does not work G6-R9, G6-R10, G6-R11, G6-R12 Beside the device table in clients.rs, keyed by the symptom somebody reports rather than by the cause — the person asking has the symptom, and which cause it is is exactly the thing they cannot yet say. Four symptoms, seven causes. Every cause carries how to tell it from the others under the same symptom, and that is the field the requirements turn on: G6-R9 asks that a wrong address, a device on another network and a server that is not running be distinguished, not listed, and a cause offered without a way to tell it apart is a guess presented as an answer. So the app that cannot find the server says every device fails alike for a wrong address, that another device in the house can reach it where this one is on the wrong network, and that nothing reaches it where the server is down — which is the one the operator can settle without leaving their chair. G6-R10 is its own symptom rather than a fourth cause of that one, because a guest network is the case that looks most like a wrong address: working internet, correct address, and nothing answers. Named as the likely cause, with nothing to change on the server — a guest network isolating clients is it doing its job. G6-R11 separates a library nobody has scanned from one an account has no permission for, told apart by whether anybody else sees content. G6-R12 covers an address that changed under a client that cached it, told apart by everybody stopping at once while a browser at the current address still works. A guard refuses any cause whose telling is too short to be one, and a second asserts some symptom still has causes to tell apart, so the first cannot pass on an empty filter. On the screen the causes are numbered only where there is more than one: a lone cause numbered 1. reads as the first of a list the reader then goes looking for.
The address as something to point a camera at G6-R4 Reading an address off a screen and typing it into a phone is where the handing over goes wrong — a household member mistypes it once and asks the operator to come and look, which is the interruption this product exists to remove. Drawn in render/qr.rs and put under the working in the front door’s answer. The ink is the light modules, not the dark ones: a terminal draws text in its foreground colour, which on the dark terminal an operator is almost certainly sitting at is the light one, so drawing the dark modules would hand a camera the code inverted. The quiet zone is painted in that same ink rather than left as background — a border of unlit cells is not a quiet zone when the screen around it is unlit too, so the code carries its own margin instead of borrowing the terminal’s. Stated rather than hidden: this is right on a dark terminal and inverted on a light one, and there is no way to be right on both without setting a colour, which this surface does not do because it writes to a pipe as readily as to a screen. Two module rows share a line as half-height blocks, because a terminal cell is about twice as tall as it is wide and a module drawn as one cell comes out stretched; a terminal that cannot draw those gets one row per line at two characters wide, the same width and twice the height, because a code that takes more screen still reads and no code at all does not. Which drawing to make is settled before the line exists rather than folded afterwards — the fold rewrites characters one for one, and these two shapes differ by how many rows they occupy. say::folding reads the latch rather than settling it, so a surface asking what a terminal can draw cannot decide it for the run. The picture goes last, under the working, because an operator who wanted the address to send has it in the second line and a drawing put above that would push the working off the screen; the guard asserts both that a code is drawn and that the address is still written above it, since drawn-but-not-written would satisfy the requirement while taking away the thing the operator opened this to copy. qrcodegen was chosen for having no optional dependencies at all, where qrcode can pull in image and fast_qr can pull in resvg — a QR code is a grid of bits and needs neither.
Which app to watch on, and where the answer is to use something else G6-R1, G6-R2, G6-R3, G6-R5, G6-R7, G6-R8 clients.rs, reachable as lemonfiber clients, /api/clients and a question on the dashboard’s list. Jellyfin’s client landscape is uneven, and the unevenness is the point: somebody sent to “the Jellyfin app” on an eight-year-old Samsung spends an evening finding out that the app for their television is nobody’s priority, and blames the stack rather than the platform. So seven devices each say how well served they are before saying what to use, and the standing is on the device’s own line — an operator scanning for which of these will be trouble is answered before any of the detail under it. A browser is named as the answer that always works and needs nothing installed, which is what keeps anybody from being fully blocked whatever their television runs; a table of native apps alone would leave the worst-served people with nothing, and that is the case this exists for. A device that is poorly served says what to do instead — a streaming stick, or casting from a phone — and a test refuses any device marked poorly served that offers no way out, because naming somebody stuck without telling them they are not is worse than saying nothing. That test also asserts some device is marked poorly served, so it cannot pass by the table having quietly gone cheerful. Two things true of every device are said once rather than per row: that all of it works on the home network and nowhere else (G6-R8), and that lemonfiber installs nothing on anybody’s device and cannot (G6-R7) — a caution repeated seven times is one nobody reads, and the renderer is held to saying each exactly once. G6-R5 was built before this slice and never claimed. address.rs returns the machine’s own .local name ahead of any recorded address where the platform publishes one, and falls back to the number with a caution that a router may hand out a different one. Where available is read as the platform publishing a responder out of a clean install — macOS and Windows do, and a Linux host mostly does not, where a name offered would resolve nowhere and fail without looking wrong. It is claimed here because the gate wants a row and a citation, and the behaviour had neither. The connection-failure and library-permission cases are the row above; who has never arrived is the row after this one, and what playback itself will struggle with is the one after that.
Who has never arrived, on the list everybody else is on G6-R13 Never having connected is what an unclaimed invitation is, so this is answered by the reading that already lists the household rather than by a search of its own. lemonfiber household — and /api/household, and the dashboard’s panel, since it is one read served three ways — gives every member with their standing, and somebody who has never signed in is named as invited, nobody has set a password yet. render/trace.rs pins that wording in a test. The distinction it refuses to blur is the useful one. An account that has been claimed but reports no activity says no sign-in recorded instead, because claiming is done by signing in on this media server: a date missing there is the server not keeping one, not somebody who never arrived, and telling the two apart is the difference between re-sending a message and wondering why somebody stopped watching. The fact underneath is LastActivityDate, which the media server omits until somebody first signs in — driven against jellyfin/jellyfin:10.10.3 — carried as Member::last_seen and published in the contract, so a browser reads the same state the terminal draws. Nothing here needed building; what it needed was saying.
What playback will struggle with, said before an app is chosen G6-R6 The fact existed and exactly one surface said it. quality set holds a preset this machine could only transcode on the processor until the operator confirms it, and after that nobody is told again — so a household meets it months later as a video that stutters on the television, and the first three things they change are the app, the television and the network, because that is the order the guidance itself suggested. The same fact now reaches lemonfiber clients as a second surface, and nothing about it was rebuilt to get there: transcoding.rs still decides it, warn_before_confirming weighed over the most demanding preset in force and the playback this platform and the recorded Jellyfin mode come to, and clients.rs turns that warning into the Straining the guidance carries. The most demanding rather than the global one: a house that left television at balanced and put film at maximum still meets this on film night, and a caution that missed it would be wrong on exactly the evening it is wanted. It names the transcode as the likely cause, which is what the requirement asks for and what merely mentioning transcoding would not be — where a video stutters, takes a long time to start, or stops partway through, the caution says that transcode is the likelier cause than the app, the device or the network, and offers the two things that stop it: a lighter preset, or Jellyfin run where it can reach the encoder. It belongs to the guidance rather than to a device, because the preset and the platform decide it between them and every device meets it, and it is drawn above the table rather than under a row — somebody reading this is deciding what to install, and a caution met after the blocks arrives after the decision it was for. Said only where it is true. A host that transcodes in hardware, a preset that provokes no transcode, and a stack with no media server at all each warrant nothing, and each reaches that through a different arm of the decision, so one test drives all three and asserts the guidance gains no sentence while another asserts the drawn report still leads with the table. Both reads behind it are best-effort by design: an unreadable choice file falls back to the default preset, which asks for no transcoding, and an unreadable environment file reads as no media server — so clients still answers in full on a machine with no stack set up at all, which is the case it is most wanted in.
What the household is waiting on, without being asked D4-R8 A ninth panel on the dashboard, beside the front door and for the same reason: a request waiting on a decision, or failed after one, is waiting on the operator, and an operator who has to think to ask is one who finds out when somebody comes to complain. Only those two states reach it. A request being fetched or already here needs nobody, and listing it would push the ones that do off a panel this size — which would make the panel worse than no panel, because it would look like it had answered. It is built from the same reading household answers with, in app/dashboard.rs, so the screen and the question cannot report different requests. A request service that could not be read says so rather than drawing an empty panel: the report already keeps that distinction — available is why a list is empty — and it is carried onto the screen rather than flattened there, because a household that has asked for nothing and a Seerr that did not answer look identical once you lose it. The layout went from four rows to five, and every_panel_has_somewhere_to_go_at_either_width counts against the sections themselves rather than a number written twice, so a panel added without a place is red rather than invisible.
Nobody in the house needs an account here D4-R9 True since the first commit and held by nothing until now, which is the whole difference this row records: the requirement survived on there being no reason to break it. Somebody who lives here signs into Jellyfin to watch and into Seerr to ask, and both authenticate against the one Jellyfin account they were given — this program is not in that path and has no notion of a second person. What makes that structural rather than a promise is that the credential surface in admission/credential.rs is identity-free: set, verifies and at take a password, an offered password and a path, and none of them takes anywhere to say whose. There is no argument to pass a name to, so there is no second person to admit, and the smallest change that would give a household member an account here is a parameter that does not exist. the_household_needs_no_account_here.rs holds those three signatures as function-pointer coercions, so adding one stops the build rather than failing an assertion — and holds the record to one field, because an owner added to what is kept would reach the same end without touching a signature. Deliberately not a sweep for the words the source uses: that would pass a login added under different wording and go red on a doc comment merely rephrased, wrong in both directions at once. The requirement’s other half — no access — was already held, by the front-door answer naming no administrative service and by reaches_the_household refusing an address that only works on the machine itself.
The person who asked hears back D4-R2, D4-R3 A request that vanishes into silence is read as being ignored, and the requester’s next move is to ask the operator in person — the interruption this product was installed to remove. Seerr sends these; lemonfiber’s job is to make sure it is configured to, and until now nothing did. The browser-push agent is the one that needs no account of its own: every other notifier Seerr offers wants a service to sign in to — a mail server, a chat workspace, a push provider’s key — so a household that has done nothing but visit the page can only be reached by that one. The occasions are named in seerr.rs rather than written as a single number, and the one easy to leave out is approved-by-policy: a household whose policy approves automatically never has a pending request, so a set built from pending alone tells that household nothing at the moment they asked — exactly the household that most expects the loop to close by itself. It is its own seeding step, not part of pointing Seerr at the media server, because that one stops at a service already initialised, which is every install after the first — precisely the ones this would otherwise never reach. Drift-aware on the same three values the download-client wiring uses and through the same reconcile: what lemonfiber recorded, what the service holds now, and what it would write. Somebody who turned this off turned it off — it is reported and left, and the baseline is deliberately not re-recorded, or the next run would read the absence as agreement and stop reporting it. A setting is always there, so the analogue of an absent download client is the untouched default with nothing recorded against it: nobody has set this, lemonfiber included. The preservation is asserted by what the service was asked to do, not only by the state reported, because a pass that wrote over them and then called it drift would satisfy the state alone. Two limits stated rather than implied: lemonfiber switches the occasions on and the wording of each message is Seerr’s, which is what D4-R3 asking for a reason rests on; and web push reaches somebody who has allowed it in a browser, which is the case the feature’s own edge table already answers — status stays visible in Seerr regardless, and the loop closes on the next visit. The diagnosis reads it too, in doctor/telling.rs — the same three values, changing nothing, so a household that stopped hearing back is reported rather than discovered when somebody comes to complain. What each outcome is worth is a different judgement from seeding’s: seeding decides whether to write, the diagnosis decides whether anybody needs telling. An operator who switched it off does not need telling — they know — so their setting passes and says which way they set it, because told-nothing and told-less are different sentences and the wrong one sends somebody hunting a fault that is not there. The one warning is lemonfiber’s own value fallen behind what it now sends: nobody chose it and nothing looks edited, while the occasions since added go unsent. Writing the check found a real divergence — the rule reading an untouched default as never-configured lived only in the seeding half, so the check would have told an operator they had switched off something they were never offered; the comparison is now one function both halves call. The endpoint and the occasion values were read from the pinned image, not recalled: the API description it ships, and the enum behind it for the bits that description only gives an example of.
What the wiring graph still owes D1-R18 Every connection in the graph is made, and every one is proven against the service that received it — nineteen rows wired with nothing skipped and nothing failed, and all ten of the dashboard’s widgets answering with data. It took three attempts to say that honestly. The first tick came from proving qBittorrent’s two connections and letting them stand for the table; the second from proving all ten widgets and letting them stand for the graph. Both were real evidence about a part, written up as a claim about the whole. What the driving found: the dashboard’s keys were published by walking the media-filing *arrs, so Prowlarr — which manages no media — and the subtitle finder, which is not Servarr-shaped, had none; the request service keeps its key in a file its manifest entry did not name, under the one config mount in the stack that is not /config; the media server keeps none on disk, so its key is minted under lemonfiber’s own name and handed back after; the listening server had no account at all, so the first run makes one and signs in for a token each time rather than storing a second copy of one secret. The identity wiring ran after the request targets that need it, so a fresh stack reported two failures and then fixed them in the same run. And two rows had never worked for Lidarr: a root folder described by path alone, which it refuses without a name and the two profiles anything beneath it is fetched at, and a download client filed under a category SABnzbd does not ship — tv and movies match its defaults by an accident of naming and music does not.
The arrs that fulfil what the household asks for D1-R17, D4-R4 The request service does not discover the *arrs; it is told about them, and nothing told it. Its whole surface here was signing in, finishing setup, and reading requests — so a household member could ask for something, have the ask accepted, and no downloader would ever hear about it. D1’s wiring table has declared Sonarr, Radarr → Seerr as request fulfilment targets since this feature was written and no acceptance criterion obliged it, which is exactly why nothing gated it and nothing noticed; D1-R17 was written for it first (spec#228). And then it was built and did not work, which nothing but running it against the real service could have shown: the registration was made by a client that had never signed in, so every attempt came back as a refusal about a credential, and the one body served both lists although the two require a different field each — television is filed in folders per season, a film has a point before which there is nothing to fetch, and the service refuses a registration that omits the one its list asks for. Both are read out of the service’s own schema rather than guessed, and both are held by test: the fields each list requires, and that neither carries the other’s. The client is now built signed-in in one place, so a caller cannot be handed one that will refuse everything asked of it. Driven against the pinned images to prove it: both targets report wired, and the request service is then asked what it holds — Sonarr at its own address with seasons in folders, Radarr at its own with availability released, each filing into the folder this stack gave it. Built in app/seed/fulfilment.rs, which reads each *arr for the profile it fetches at and the folder it files into, because the request service must name both when it hands a request over and an operator may have renamed what setup created. An *arr that cannot answer, or has no profile or folder to name, is left out rather than registered half-configured — a target the service holds but cannot fetch through is worse than one it does not hold, because the request is accepted either way and only the second is visibly missing. The other half is what closes D4-R4: only the *arrs actually in the stack are offered, so television is not offered where Sonarr is not running, and the two *arrs filing music and books are not targets at all — the same rule, not an omission, since the request service fetches film and television and nothing else. Registration follows the application wiring rather than the baseline: matched by host and port and never by name, so an operator who renamed one is not handed a duplicate, and one already there is left exactly as it is rather than rewritten, which is what preserves whatever they changed about it. The *arr is named to the request service by its container name, not a loopback address — the request service is itself a container, where 127.0.0.1 is the request service and not the *arr, an address that would resolve and answer wrongly. What is sent is asserted off the request rather than taken on trust.
Asking for part of a series, and only what you may ask for D4-R11, D4-R13 Met by the request service the stack pins, not by code here — said plainly so nobody later goes looking for the lemonfiber that implements it. Both were verified against the pinned image rather than taken from this feature’s own edge table, which says what the product intends rather than what the shipped service does. D4-R11: the request body takes seasons as either a list of season numbers or all, so somebody can ask for the two series they are missing without the other six being fetched again — the requirement’s “without re-acquiring existing content” falls out of asking for less rather than out of anything comparing libraries. D4-R13: requesting film and requesting television are separate permissions, and the control that offers a request is drawn only where the viewer holds one of them — so what somebody may not ask for is not offered, rather than offered and then refused, which is the distinction the requirement draws. What is deliberately not claimed from the same reading: D4-R10 describes duplicates merged with every requester notified, and the service refuses the second request outright and keeps no second requester, so there is nobody to notify — corrected in the spec instead of ticked here. D4-R5 and D4-R6 look met but by a different mechanism than their wording implies, and are left for a check against a running service rather than its source.
Something already here, and something not out yet D4-R5, D4-R6 Read out of the pinned image, and the second one turns out to rest on a value this product sets. D4-R5: the request control is offered only where the service holds the media as unknown to it, or as deleted with nothing outstanding — so something already in the library is never offered as a request at all. It is reported instead, which is the distinction the requirement draws: not accepted and then explained, but not accepted. That reading corrects an earlier one of mine that had taken a nearby availability check for the request gate; it was the blocklist control. D4-R6: an unreleased thing is unknown to the service, so it is offered and accepted — and what keeps it from failing is minimumAvailability, which the request service passes straight through when it adds the film to Radarr. seerr.rs sends released with every fulfilment target, so Radarr holds a film that is not out yet rather than searching for something that cannot be found and failing. Announced or in-cinemas would both have meant hunting for a thing that does not exist. Stated exactly: it is accepted and waits, and what a reader sees meanwhile is the request’s own state beside the release date rather than a badge saying those words.
Ready means the library has it D4-R12 The requirement exists because “ready” is worth nothing if it is said before the thing can be played, and the shipped service is built the way it asks. The downloader’s view never marks something available. Its scanner moves a request to processing, or back to unknown where the download is gone, and nothing more — availability is set only by the scanner that reads the media server’s library. So a file that has finished downloading but has not yet been picked up by Jellyfin reads as still on its way, which is the true statement, and the household is told it is ready only once the library holds it. Read out of the pinned image rather than assumed: the two scanners set different things, and that difference is the requirement.
A request that keeps failing is not failing quietly D4-R14 The requirement reads as though it asks the stack to give up, and it does not. Its own edge case says don’t retry silently forever — what must stop is the silence, not the retrying, and a fetch that keeps being attempted is fine so long as nobody is left wondering. Both halves are this product’s own doing and both landed this milestone. The requester is told because COULD_NOT is among the occasions the request service is set to send on, so a fetch that fails reaches the person who asked rather than sitting in a list they never open. The operator is told because the dashboard’s ninth panel filters on exactly the two states that want a person — a request waiting on a decision, and one that failed after it — so a repeatedly failing fetch is on the screen the operator already looks at, not somewhere they would have to think to go. Between them the loop closes at both ends, which is what the requirement is protecting: somebody asked, it is not working, and neither of the two people who could do something about it has to discover that by accident.
Nothing at the quality you chose is its own answer D4-R7 Two situations looked identical and one of them has a remedy. A trace of something monitored that nothing has been grabbed for went through Stage::Monitored, whose reason asserted the indexers returned nothing — a cause no service in this stack can see, and one that is simply false whenever releases existed and the quality profile rejected every one. The stage that means the second thing, Stage::Found, was unreachable in production: Outcome::of_event maps no history event to it and of_queue_state never returns it, so it read as covered while nothing could ever arrive there. Two halves fix it. The read stops claiming a cause it cannot know — resting at monitored now says nothing has been grabbed yet, that no search was run, that whether the indexers carry nothing or the quality in force wants none of what they carry is not known, and what would tell the two apart. And the thing that can tell is asked for. The ReleaseProbe vocabulary in service/quality.rs already drew the distinction for the doctor (QUAL-2 against QUAL-3) and the walkthrough; app/trace.rs now reads it too, and NoneMatch — releases out there, the profile wanting none — carries the item to Found with the reason naming the quality and naming easing it as the way out. NoneFound leaves it at monitored and says the indexers carry nothing, and that this is not the preset’s doing, so nobody eases a quality that rejected nothing; a search that could not run, or that answers about other content, settles nothing and says so rather than becoming an absence. The search is a write on every surface, because it spends one real request against the daily allowance the indexers hold the operator to: lemonfiber trace … --search on the command line, the search action on the web — required rather than defaulted, since a trace that asks the indexers nothing is the read /api/trace already serves — and on the terminal under the answer that named the gap, which is where the widened diagnosis is already offered. The show and the season the reading was narrowed by narrow the search, so a run asked about one season does not spend it on every season there is. The gate is asserted on the request having been made, not on the reading it would have produced: the cost is the search, and it is spent whatever the trace then says.
Asking for something already asked for D4-R10 Claimed against the corrected requirement, not the one it replaced. The old wording asked for duplicates merged with every requester notified on availability; the request service holds one request per thing by whoever asked first and refuses a second outright, keeping no second requester — so there was nobody to notify, and this product could not supply the difference, configuring the service rather than handling requests. The requirement now says what happens, and what happens satisfies it: a second ask creates no second request, and the person asking is told it is already coming rather than being left to wonder whether theirs registered. What they lose is a notification when it lands; what they keep is an answer now, and the thing itself appears in the library like anything else.
One way in, and it is the account somebody already has D4-R1, D6-R1 Built with the seeding and never cited, so it read as undone: seerr.rs signs in at /auth/jellyfin, naming where the media server is and what type it is, and configure_identity is what points the request service’s identity there for good. A household member therefore signs in to Seerr with the same credentials that let them watch. Where the media server is, is given in pieces rather than as an address, because the request service assembles the address itself — scheme, host, port and base path, joined back together at its end — so handing it a whole one produces a host built from a scheme and a port that was never set, and it refuses that as an invalid address. Taking the address apart is that service’s peculiarity rather than the household’s, so it happens in the client rather than in what the rest of this workspace passes around, and an address that cannot be taken apart is refused before anything is sent rather than guessed at. And opening a session names no media server at all, which is the other half of the same mistake: a service already pointed at one refuses an address outright, because moving a household’s identity source out from under them is not a thing a sign-in should be able to do — so a sign-in carrying it could only ever work on the one run that set the service up, and never on the runs that read. That is why the two are separate calls rather than one with a flag. The session is opened where the reads need it: what the household is told is reconciled on every pass, and the pass that reconciles it is by definition the pass where the service was already set up. Driven end to end against the pinned images rather than asserted: a fresh Jellyfin and a fresh Seerr, seeded by this product, then somebody invited through lemonfiber invite, claimed by setting a first password, and signed in to the request service with those same credentials — which is the whole of what the requirement asks and the only way to find out that it held. What makes the second registration unnecessary is not a setting that forbids one — it is that there is nothing else to sign in through: the whole surface this program asks the request service for is four paths, and exactly one of them is a way in. one_way_in_and_it_is_the_media_server.rs holds that set to one entry, so a local sign-in added beside it is red rather than quietly available, and holds the sign-in to still naming which server it authenticates against — the endpoint alone would leave the service to guess, and the household’s existing account is only the one that works because the server is named. Read from the shipped half of the file, so a path written for a fake to refuse does not count as one this program asks for. Deliberately not a claim that Seerr’s own local sign-in is switched off: nothing here touches that setting, and the requirement asks that no separate registration be required, which an identity source everybody already has an account with satisfies whatever else the service permits.
A door that is down and a door with no way to it are two answers G5-R13 A door is unreachable two ways and they are fixed at opposite ends — one by starting a service, the other by giving this machine an address another device could arrive at — so a household member who cannot get there needs to know which before they start blaming their own device. Only one of them was a state. The other was a sentence appended to whatever was said, and the standing beneath it read established, which the feature defines as running and reachable: a browser, a script or a dashboard reading the field was told the door was fine while the prose under it said there was no address to arrive at. The address is part of the answer now — Stranded is the door that is answering with no way to it, Unreachable stays the service that is not answering, and each carries the one thing that fixes it. The fixture the older tests were built on answered no name, so the assertion that a running Seerr is established was an assertion about a door nobody could arrive at; it is given a name, and the two states are pinned against each other rather than each alone, because a phrase distinguishes only if the other phrase is not it. What the host cannot do is test a particular device, and the feature’s own edge-case table says so now: a door reported established that a device still cannot open is that device’s side of it.
The door is chosen, and nothing beside it is a way in G5-R10, G5-R12 The call this feature makes — that a household begins where they can ask for something — is one an operator may reasonably disagree with about their own stack, so it is a setting: LEMONFIBER_FRONT_DOOR names a service by the id the stack declares it under, read the way every other setting here is read and with a blank taken as absent rather than as a door named nothing. What a setting cannot be is a way around the tiers. A name is a request rather than an instruction, and door/chosen.rs obeys it only where it reaches somewhere this stack already publishes to the household and already holds to be a place to begin. A service bound to this machine alone is refused, because obeying would publish an address for something an arrival could reconfigure the stack from — the very thing the loopback binding withholds; the index over every service is refused in the register’s own words rather than in a second set that could drift from them; and a name behind which this stack declares nothing is refused as that. A refusal leaves the worked-out door standing rather than answering that there is none: what was wrong is the file, not the stack, and taking a true answer away over a misspelt line would send the operator looking in the wrong place. What it does not do is go quietly — how the door was chosen is a field on the answer beside the standing, for the reason the standing itself is a field: an operator reads the sentence and a browser, a script and a panel read this. The consequence is stated in both places it is met (G5-R12): beside the setting, in config::display’s register of what is shown and why, and in the answer itself — a named door is the one thing lemonfiber stops keeping right, so a stack that later grows a request surface goes on sending the household to what was named. Nothing household-facing names an administrative service (G5-R10): the surfaces themselves are third-party and not this product’s to change, so what is held is what this product hands the household — the answer every rendering of it draws from. what_the_household_is_handed.rs reads the shipped stack’s own tiering rather than a list written beside the test, and holds two properties over it: no service the household tier does not publish is named anywhere in the answer, put to every one of them in turn through the setting as well as by default; and the only address on the answer is the door’s own, so what stands beside it is a name and a reason with nothing to follow.
The address reaches the third place it was owed, which is an invitation G5-R6, G5-R7, D6-R3, D6-R6, D6-R11 The two easy places were already built — the end of setup, and a panel on the dashboard. The third was an invitation, and one did not exist: the word appeared nowhere in the tree, so G5-R6 was a constraint on a thing rather than a description of one. It exists now. lemonfiber invite ana makes the account, and render/invitation.rs answers with the name to sign in as, one address, and that address again as a code for a camera — the recipient is usually holding the phone they will watch on, and typing a LAN address and then a password on a phone keyboard is the friction that makes people give up before they start. Where the address works is said beside it rather than left to be discovered: the stack is published to the home network and nowhere else, so an address that opens nothing from a phone on mobile data is not broken but asked from the wrong place — and somebody who was not told reads a working invitation as a dead link and gives up without saying so. It sits on the line under the address, which the test pins by position rather than by presence, because a true sentence at the bottom of a screen is one nobody joins to the address it is about. Nothing is written down on this machine. An invitation is an account with no password on it, which is a state the media server already holds: whether it is claimed is a field, and when it was offered is in the record the server keeps of things happening. Both are read back rather than remembered, so an invitation survives this program being closed, reinstalled, or run from somewhere else — and there is no page of lemonfiber’s for anybody to open, because it runs nothing between commands and a link only it could answer would stop working the moment the operator shut the terminal, which is exactly when the message gets read. The address is the one a person can open, which is neither of the two the stack carries for a service: http://127.0.0.1:8096 is this machine talking to itself and http://jellyfin:8096 is one container talking to another, and an invitation carrying either sends somebody an address that resolves nowhere they are standing. It is built by the same derivation the front door uses — the name this machine answers to on the network, asked at the moment of asking so a machine renamed since the last look answers as it is — with the media server’s port rather than the door’s. A stack with no name and nothing recorded has no address to send, and that is said as a refusal rather than papered over with a default: an invented address is the one thing that gets forwarded to somebody else, and the operator would learn it had failed from whoever they invited. What is worth knowing about the address travels with it, because this is the copy somebody keeps — an address that is a number is one a router can hand elsewhere, and the person whose bookmark stops working is not the one who could find out why. The address is the media server’s, which is not always the door, and D6 says why it must be: setting a first password happens in Jellyfin, and Seerr authenticates against Jellyfin rather than holding credentials of its own, so an account with no password cannot be claimed through the door whichever service G5 picks as one. What G5-R6 is for is what the operator experiences — one address chosen by lemonfiber rather than four to choose between — and that holds. After the account is claimed the household member uses the front door like everybody else. Expiry happens on the way past rather than on a clock: nothing runs in the background to sweep at the moment an invitation runs out, so the sweep is done the next time one is offered, and what it took back is named rather than done quietly — somebody who invited a person last week and heard nothing would otherwise have no way to learn the account is gone. Claiming sets a password, which happens once and cannot be undone by a second arrival, so an invitation is spent by being taken up. How far back the record is read is a different moment from the window it is judged against, and a much longer one: the server answers with what happened since the moment it is given, so reading from the moment an invitation runs out returns only the ones still standing — the two are opposite halves of the same line, and a sweep built on one value finds nothing while looking exactly like a stack with nothing to sweep. The test asserts the moment asked for rather than what came back, because the fake matches on path and hands over its record whatever it is asked; the assertion that a withdrawal was reported passes either way. Read against the pinned image rather than reasoned about: minDate returns entries at or after it. A rehearsal writes nothing and still says everything, which matters more here than on a command that only adds: both halves of this one change the household, and the half that removes accounts is the one nobody would want rehearsed by doing it. Every part of the answer is known before anything is written — the name is the one asked for, the address is the stack’s, and what has run out has just been read — so --dry-run reports the whole invitation and marks it as made by nobody. It is asserted by what left the machine rather than by what came back, because an answer that reads like a rehearsal is exactly what a run that wrote anyway would also print. Offering somebody twice is a thing operators do, and it is answered rather than refused (D6-R6): they forget, or the first message went unanswered, and what they want is the thing to send. So what is already there decides what there is to say — an account nobody has claimed is that message again, and one whose password is set means that person is in the house, with no code to scan and nothing to claim. The match ignores case because the media server refuses a name differing from one it holds only in case, and refuses it as 400 with a link to the specification of that status: a match missed here reaches the operator as the server’s own word for something they did on purpose. The name reported back is the account’s rather than the one typed, because that is what somebody signs in as. A name is trimmed first, since the server keeps the spaces and treats the result as somebody else — a name with a space on the end, beside the same name without one, is two accounts that read identically in every list either appears in — and a name that is only spaces is refused here, about the name, rather than by the server, about HTTP. An invitation that has run out is a different case again: it is taken back on the way past, so the name is free and a fresh one is made, and an invitation nobody took up does not leave the name unusable by the person it was for. The one rule that must not fire is the one held by test: an invitation nobody can date is left standing, because the record of when an account was made is kept apart from the account and does not last forever, so withdrawing on a guess takes away an account somebody is about to use.
An invitation is a link, and the same link as a code D6-R4 The address is put as its own line and then drawn again beneath it from that same string: render/invitation.rs writes report.address and then hands that one value to qr::rows, so the two cannot disagree — a code encoding an address the line does not show is the failure worth designing out rather than testing for. The code is half-blocks where the terminal draws them and two-character ASCII columns where it cannot, chosen before the line exists because the fold rewrites characters one for one and the two shapes differ in row count. It is drawn because somebody being invited is usually holding the phone they will watch on, and typing an address and then a first password on a phone keyboard is the friction that makes people give up before they start. The one invitation with no code is the one with nothing to claim: a person already in the house is shown the address, because it is still where they sign in, and no code and no set-a-password line, because that would be telling them to do again what they have already done.
What an invitation does not hand over D6-R2, D6-R14 Both are absences, and an absence stops being true the moment somebody adds the thing — so both are held to the traffic and the types rather than to the prose describing them. D6-R2: an invitation is an account with no password on it, which is why nothing has to be running for somebody to claim it later. jellyfin/household.rs’s invite sends {"Name": name} and the port’s signature is invite(&self, name: &str), so there is nowhere to put one; the person invited sets the first password themselves, at the media server, where the operator cannot read it. an_invitation_sets_nobodys_password.rs drives the whole command through dispatch and asserts the creation body whole and the invitation’s field set whole, so a password added under any spelling fails it. It also states the part a filter would hide: one password does travel, this program’s own recorded credential, and the claim is that it goes only to its own sign-in — written as where it goes rather than as an exclusion list, because an exclusion list is a place to add a second entry. D6-R14: a household member holds no credential that could open this program and there is no way to give them one. The credential surface is identity-free — a password is set, read back and verified with nowhere to say whose (the_household_needs_no_account_here.rs) — so there is no second account to make, and the web surface admits that one operator credential behind a token minted at start and printed on the terminal that started the process, with exactly one route reachable without it (admission.rs).
The household list is a list of members D6-R7 It was a list of requesters wearing the name of a list of members. lemonfiber household built its people out of the request service’s requests, so somebody who held an account and had never asked for anything did not appear at all — and neither did an invitation nobody had taken up, which is the entry an operator is most likely to be looking for. The people now come from the media server’s accounts, which is where being in the household is actually decided. All three of what the requirement asks for are one read, verified against jellyfin/jellyfin:10.10.3 rather than reasoned about: GET /Users carries PolicyEnableAllFolders, EnabledFolders, MaxParentalRating, IsAdministrator, IsDisabled — and LastActivityDate, which is absent until somebody signs in and therefore missing on exactly the unclaimed invitations and nothing else. So activity is a field on the account, not a scan of the activity log. Library identifiers are turned into the operator’s own names by one read of /Library/MediaFolders for the whole household rather than one per member; an id that list does not name is kept rather than dropped, because a library missing from the list is still one that member can open and showing nothing would read as access they do not have. A request service that will not answer now costs the requests and not the household — who is here is the media server’s fact, and blanking the list because a second service is down would be this same defect one service along, so the refusal is reported as a finding beside a list that still reads. The join is on the lower-cased name, because the media server treats two names differing only in case as one person; and a request whose asker holds no account is said in a finding rather than dropped, since a request outliving the account that made it is exactly what this list is read to find. Built in app/household.rs over a widened Household port, rendered with access first and activity last in render/trace.rs — where an unclaimed invitation says so instead of saying “never signed in”, which is true and useless.
An invitation outlives a request service that is down D6-R12 Nothing told the request service about anybody. A household member existed on the media server from the moment they were invited and became known to the service they ask through only if they happened to sign in to it — so the requirement’s “complete the link later” had no earlier half to complete. The link is POST /api/v1/user/import-from-jellyfin, read out of the handler in the pinned v3.3.0 rather than from its OpenAPI, which does not describe it: it fetches the media server’s accounts itself with the credentials it was set up with, keys them by normalizeJellyfinGuid, and skips anybody it already holds. That idempotence is the whole design. Because a member it knows is a no-op, every run names the entire household rather than only the person just invited — so a link an earlier run could not make is completed by the next ordinary run, with nothing written down in between, which is the only kind of “later” that survives this program being closed, reinstalled or run from another machine. It is the same drift-aware shape the download-client wiring uses, arrived at from the service’s own behaviour rather than imposed on it. The invitation is never held back for it: the account somebody watches with is the media server’s and stands whether or not a second service is up, so a refusal is reported rather than allowed to refuse the invitation — an operator inviting somebody during an outage has still invited them. What they cannot do yet is one line, and that line says nothing is half-made and nothing needs undoing, because that is the fear reading it provokes. The three states are distinguished rather than folded into a flag: told, could-not-be-told, and nothing-tried — a rehearsal or a stack with no request service at all, neither of which a later run would put right. Invitations just taken back are left out; somebody already in the house is named once rather than twice. Built in app/invite.rs over a widened Requests port, with the household port moved into service/household.rs so the ports file was split rather than pushed past its cap.
Removal reaches both services, and says what it costs first D6-R8, D6-R9 A household member exists on two services, so removal has to reach both or somebody keeps half of what they were given. The media server goes first, and the order is the claim: the request service authenticates through it, so once the first account is gone the second cannot be signed into either way — a failure after that leaves an account nobody can use, where the opposite order leaves somebody who can still watch. DELETE /api/v1/user/{id} on the request service and DELETE /Users/{id} on the media server, both driven against the pinned images. D6-R9 is answered by facts rather than by choices. The media server’s removal takes only an account identifier — confirmed from its own /api-docs/openapi.json, which lists one parameter — so watch history goes with the account unconditionally and is stated that way rather than as something that could be arranged. The request service destroys that person’s requests, and does it by hand rather than by cascade so a title still waiting reverts to unrequested instead of pointing at nobody; the count is said to stop existing, because “removed” would let somebody read it as surviving under another name. Nothing happens until --confirm, following forget and reset, and every figure in the unconfirmed answer is read rather than estimated — which is what makes the confirmation worth asking for. The account this program signs in as is refused here, not by the server. The media server does refuse it, but with 400 where another administrator remains and 500 where it is the last account; neither is a sentence an operator can act on, and both would arrive after they had already confirmed. It is identified by Policy.IsAdministrator, which the household read already carries. Somebody the request service never heard of is nothing to revoke rather than a revocation that failed — its lookup answers 404, and reading that as a refusal would report a failure to remove a thing that was never there. A request service that will not give the account up leaves it and says so, with the next run able to take it. Built in app/remove.rs and render/removal.rs, where the cost is printed above the line that says how to go ahead — an operator reading it is deciding, not being informed.
A reset is an invitation again D6-R10 The operator cannot learn the password because no password is chosen. POST /Users/{id}/Password with {"ResetPassword": true} puts the account back to having none at all — verified against jellyfin/jellyfin:10.10.3 end to end: the account claims a password (HasPassword: true), the reset answers 204 and returns it to false, the old password is then refused 401, and an empty one is accepted 200. So a reset returns the account to exactly the state an invitation leaves it in, and HasPassword is this product’s whole claimed/unclaimed distinction. The request carries a flag rather than a value, so there is nowhere to put a password even in error — the same structural reason D6-R2 holds on the invitation path, and the reason this needed no new promise about secrecy. What every surface hands back is an Invitation rather than a report of its own, because after this the thing to send is an invitation: the same address, the same code, the same line about being asked to set a password. A second shape would be a second account of one message and the two would drift. No confirmation is asked: nothing is destroyed and nothing is listed first, and what ends is a password nobody here knew — asking somebody to agree to that is asking them to weigh a loss they cannot see. The one account no surface will reset is the one this program signs in as, refused in app/invite.rs rather than by the server, for the same reason a removal refuses it. The setup both halves share — where the media server is, and the address a person reaches it at — is read once in reaching, so an invitation and a reissue cannot disagree about where to send somebody. Building this found a way to lose an account, and the fix is what makes the window real. An unclaimed account is what invite’s expiry sweep withdraws, and withdrawing means deleting; a reset account is unclaimed while the record that dates it is of the account being made, which for somebody months into the household is long past the window. So reissue ana followed by any later invite would have taken Ana’s account and her watch history immediately — not after 48 hours, but before she was ever told there was a window. Every existing test passed, because none of them reset an account and then invited somebody. Driving jellyfin/jellyfin:10.10.3 showed a reset writes its own record: UserPasswordChanged, against the account, timestamped when it happened. offered now dates an invitation by the latest record rather than the first, so an account offered again runs out from when it was offered again. It does still run out — D6-R3 asks that an invitation not claimed does not stand — and because what is withdrawn here is an account somebody has watched on, the message says the consequence in those words rather than leaving it to “lapses”: set a new one within 48 hours or the account is removed. InvitationStanding::Reset exists for that sentence, and invite reaches it too, so an operator who forgets they already reset somebody is told what they actually found.
An expired invitation keeps its account D6-R13 The account is the person, so offering an invitation again has to land on the one that already exists. Everything a household member accumulates hangs off the identifier the media server gave them — what they have watched, and the link the request service holds — and a second account under the same name is indistinguishable from the first in every list either appears in while being the wrong one for anything else in the stack to mean. What this used to do was the opposite, deliberately. already_here skipped anybody the sweep was about to take, on the reasoning that an invitation which had run out was one this run was replacing — true when the only way to offer again was to withdraw the account and build another, which is to say delete the member and make a new one. It is backwards now: an invitation is dated by the record of a password moving off the account, and POST /Users/{id}/Password with {"ResetPassword": true} writes one whether or not there was a password to take — verified against jellyfin/jellyfin:10.10.3, which answers 204, records UserPasswordChanged at that moment, and leaves HasPassword false. So the same account is offered again with a real window on it, keeping its identifier and everything hanging off it. The sweep goes around that one account and nobody else’s: another member’s expired invitation is still taken back, or invitations nobody claimed would stand for ever. A failure to date it again is refused as INVITE-5 rather than glossed over, because the account is fine and it is the message that would be wrong — an invitation dated when it was first made has already run out, so the operator would send somebody a window that the next run takes their account away for missing. The standing is Made and not Waiting: an invitation that ran out does not still stand, one is being made now, and that it is made on an account they already had is the requirement being met rather than something to report.
Access is chosen when somebody is invited D6-R5 There was no write path at all. The household read carried Policy off GET /Users into a Deserialize-only shape, so this product could say what everybody was allowed and could not set it — which made the requirement a description of a screen in the media server rather than of anything here. lemonfiber invite ana --library Films --age-limit 12 makes the account and writes what it may open in the same run. That it is the same run is the whole of it: an account made open and narrowed afterwards is open for as long as it takes anybody to remember, and the person most likely to be given a limit has already been handed the address. The write was driven against jellyfin/jellyfin:10.10.3 rather than read about. POST /Users/{id}/Policy answers 204; PUT and GET on the same path answer 405. It will not take a body naming only what changed — a partial one comes back 400 with AuthenticationProviderId and PasswordResetProviderId named as required — and, worse, a body carrying those two and nothing else is accepted and puts every other field back to the server’s own default. So a lean body would have silently undone every setting an operator had made in the media server’s own screens, and would have looked like it worked. jellyfin/household.rs therefore reads the account’s whole policy off GET /Users/{id}, writes over it only what was chosen, and posts it back — confirmed by reading GET /Users afterwards. Field by field, not all at once: an operator who set an age limit said nothing about libraries, and a value written for what nobody mentioned would widen a narrowed account back to every library on the way to narrowing what may be watched on it, silently. So there is deliberately nothing in the port that spells “every library” or “no limit” — naming neither is saying nothing. EnabledFolders wants the identifiers /Library/MediaFolders gives, and the operator names libraries the way the media server’s own screens name them, matched without regard to case. The age limit is said as the age the stored number already is. MaxParentalRating is a bare integer, and reading GET /Localization/ParentalRatings off the pinned image shows what that integer means: the server puts every certificate against the age it is for — TV-Y7 at 7, 12A at 12, PG-13 at 13, 15 at 15, 18 at 18. So there is nothing to translate, and nothing is translated. Mirroring certificate names was considered and rejected: the same endpoint read under a different country gives different names for the same numbers — U at 0 and PG at 8 for the United Kingdom against G at 0 and PG at 10 for the United States — so a product that ships to households anywhere would have had to pick whose certificates, and half of them (PG-13, R, U) carry no number a person could read off them. age_limit.rs instead holds the steps offered — 0, 7, 12, 15, 18, each with who it suits — and the words for one: anything, only what suits everyone, nothing above about 12. Those same words are what a household read says it back in. standing() in render/trace.rs printed the bare number, so an operator who chose 12 would have read “nothing rated above 12” against a chooser that said something else; both ends call the same function now, and a limit that is none of the steps offered — one set in the media server’s own screens, or by whoever ran the stack before — still reads as the age it is rather than being dropped or rounded to a step. Everything is settled before an account exists. The library names are resolved against /Library/MediaFolders before the account is made — in a rehearsal too, so --dry-run refuses what the real run would refuse. A refusal after the account was made would leave somebody holding an open account they were meant to be given a narrow one, and the one case where that can still happen — the policy write failing on an account that now exists — says so in those words rather than as a bare failure, because an operator told only that something went wrong would not know whether to invite again or to go and narrow an account already there. A library nobody holds is refused with the ones there are named. Reachable from all three surfaces: --library and --age-limit on the command line, libraries and age_limit on the invite action (asked.rs refuses both to reissue and remove, which make no account), and on the dashboard as the two answers after the name — the libraries typed, because this screen does not reach the media server between a keypress and the frame after it, and the limit taken off a list built from the core’s own steps and labelled in the core’s own words (acting/inviting.rs).
A limit is said in the certificates the household already reads D8-R1, D8-R12 The number was the whole answer, and the number is not what a parent chose. An age limit reached the surfaces as nothing above about 18 and stopped there — true, and misleading against half the world’s rating tables. GET /Localization/ParentalRatings answers with the media server’s own certificates against the ages it holds them at, and the answer is the operator’s country’s: driven against jellyfin/jellyfin:10.10.3, GB gives U at nought, PG at eight, 12A at twelve, 15, 18; US — which is the default — gives G, TV-Y7 at seven, PG at ten, PG-13 at thirteen, R at seventeen. So under an American table the step labelled holds back only what is meant for adults holds back nothing an American calls adult, and the ladder lemonfiber offers names not one certificate. rating.rs therefore says a limit as the certificates on either side of it — what it still allows and the first thing it holds back — because either alone misleads: what is allowed without what is held back reads as a limit that stops nothing. Both sides are read off the server’s own table, once for the whole household, and a table that will not answer costs the names and not the limit. The mapping that stands in is stated wherever it is used, in as many words, on the answer itself rather than only in .docs/architecture/parental-controls.md — a certificate said to be this household’s when it is this program’s is exactly the claim a parent would act on. One entry per step, so no step is ever bare, and a test holds the mapping to naming every step age_limit.rs offers. The read answers before the setup wizard has run and without authentication, which is why it costs nothing to ask for. The sentence lives in one function that both the surface which sets a limit and the surface which reports one call, because a household list naming a limit differently from the chooser that set it is two surfaces disagreeing about one setting. One row of that table carries no age at all — the server’s name for content it has no rating for — and it is dropped on the way in, because it is not a certificate and what to do about unrated content is a different question.
One setting, and both halves of what it means D8-R2, D8-R3, D8-R4, D8-R5, D8-R6, D8-R7, D8-R10, D8-R13 A limit reached one service and the hole was the other one. --age-limit wrote a policy on the media server and said nothing at all to the request service, so a child who could not watch something could still pull it into the library — half a limit, which looks exactly like a whole one. The request service has no notion of a content rating: its API, read from /app/seerr-api.yml inside the pinned image, carries quotas, regions and a permission bitfield and nothing that limits a request by certificate. So the two halves cannot be one setting written twice, and what it can be told is the difference that matters to a household — whether what somebody asks for arrives unseen. Narrowing somebody now takes the approval bits off their account (ADMIN, AUTO_APPROVE, and the four beside them, read off /app/dist/lib/permissions.js in the same image rather than recalled), leaving every other permission as it was: a restriction on watching is not a reason to stop somebody raising an issue. The disagreement is detected and named, not left to be spotted in a column — household reads both services and reports inconsistent for anybody limited on one and not the other, with a finding saying it in a sentence. A service that could not be asked is left out rather than reported as agreeing or disagreeing, because an unread answer is not a disagreement and would send an operator hunting a defect in a service that is merely down; an administrator is left out too, since that service treats one as holding every permission. Unrated content is a choice with a stated cost. A rating limit cannot decide about a thing that carries no rating, and a great deal carries none — so anybody being narrowed has it held back unless the operator says otherwise, and what was applied travels back on the answer that applied it and reads on every member’s line, because a child who cannot find half the library is either this setting or a defect and silence does not say which. Jellyfin keeps it as BlockUnratedItems; lemonfiber writes all nine kinds or none — each written and read back off the image — because holding back an unrated film and letting an unrated series through is a distinction nobody asked for and nobody would find. Libraries stay independent of ratings: naming some is still saying nothing about a limit, and setting a limit is still saying nothing about libraries. A change takes effect without anybody signing in again — driven against the image: a member signs in, the policy is rewritten to a lower limit, and the same token reads the new limit back on the next request, no session invalidated and nobody asked to sign in again. And it follows the person, not the device: everything written sits on the account, and the policy’s EnabledDevices/EnableAllDevices are deliberately untouched — a restriction that followed the device would let a child watch anything on the shared television and stop an adult watching it on the same one. What this is, and what it is not, is said on the surface rather than only in a document: age_limit::A_FILTER_NOT_A_LOCK is carried on every answer that sets a limit and printed under every household list where anybody carries one, because overstating protection is worse than an accurate modest claim — a parent may rely on it. Reachable in full from all three surfaces: --unrated block or --unrated allow on the command line, unrated on the invite action, and a third list on the dashboard, asked only where something was actually narrowed so the ordinary case still costs the same two keypresses. Built in rating.rs, jellyfin/household.rs, seerr.rs, app/invite/allowing.rs and app/household.rs; driven end to end in what_an_invitation_lets_them_watch.rs.
What the media server does with a limit, once it has one D8-R8, D8-R9, D8-R11 Driven against jellyfin/jellyfin:10.10.3, which needed the one thing no fake supplies: a library with certificates on it. These three are the media server’s behaviour rather than this repository’s — nothing lemonfiber draws lists content to a household member — so the only honest way to tick them was to build the catalogue and ask. That catalogue is now a_library_with_certificates_on_it.py: its own container, eleven titles whose .nfo files carry a certificate in <mpaa>, and a member holding the policy jellyfin/household.rs writes. Nothing in CI runs it and it gates nothing; it is kept so the next claim about what the server shows is checked the same way rather than reasoned about. Against a member limited to 13 with unrated content held back, four of the eleven are offered and every read agrees on which four/Items, /Users/{id}/Items and /Items/Latest each answer four, the TotalRecordCount beside them is four rather than eleven, and /Items/Counts says MovieCount: 4 where the administrator’s own read of the same endpoint says eleven, so the count gives nothing away either. A withheld title is absent, not shown and refused, and there is no field on it to flag because there is no item: asked for by the identifier the administrator can see, /Users/{id}/Items/{item} answers 404 and /Items/{item}/PlaybackInfo answers 404 — not 403, not a playable-false field on something still described — and searching for each withheld title by its own name returns nothing. By the time a refusal could happen the item does not exist for that account, which is a stronger thing than the requirement asks for. A certificate revised upstream moves the title in both directions on a token nobody re-authenticated: rewriting an item’s <mpaa> and sending POST /Library/Refresh — the same call jellyfin/library.rs already makes — took one whose rating rose out of the member’s listing and into 404 within about a second, and brought one whose rating fell back to 200. The condition worth naming is that a scan re-reads only what it can see has changed: the same content change with the file’s modification time put back afterwards left the old certificate in place and the member kept the title, while a forced POST /Items/{id}/Refresh?metadataRefreshMode=FullRefresh&replaceAllMetadata=true re-read it in two seconds and the member’s own read went 200404 with it — so re-evaluated on metadata refresh is true of a refresh that re-reads, and a library scan is one only when the file has moved. Recorded beside it because an operator will meet it: this server schedules no metadata refresh at all, its only library task being Scan Media Library every twelve hours, and a library’s AutomaticRefreshIntervalDays is 0. And the catalogue answered a fourth question nobody had asked, because a household’s files do not come from one country: the lookup crosses country tables (15 and 18 resolve under an American server, R18 is held back at every step because GB puts it at 1000), a country prefix is stripped (GB-18 reads as 18), an unrecognised certificate with a number in it is read as that number (MADEUP-9 → 9), and one with neither table nor number (TOTALLY MADE UP) is treated exactly as content carrying no rating at all — it appears only where unrated is let through. So the conservative default already ticked above is what closes the mixed-table case, and an operator who lets unrated content through has let unrecognised certificates through with it. All of it is written up in .docs/architecture/parental-controls.md.
Every outbound request is enumerable, and each is switched off on its own G8-R3, G8-R5, G8-R9, G8-R12 outbound/, config/reaching.rs, lemonfiber outbound · /api/outbound · the dashboard’s list of questions. Enumerable by the operator is a surface, not a comment, and this is the surface: six requests lemonfiber makes on its own account, each with where it goes as this machine is configured, exactly what travels, whether it is on, the setting that switches it off, and what stops working once it is. Where each goes is read rather than written down — the registries come from whichever images the manifest declares, the echo sources are the ones in force, and the indexer is wherever the operator pointed it with its query stripped, because an indexer authenticates by one. The switches reach the code that would make the request, which is the half that makes them mean anything: a refused fetch is a refusal naming the setting rather than a run that reports fetching nothing, a start is given --pull never so Compose cannot quietly fetch what is missing, the guide probe reports skipped with the reason and never opens the connection, and a credential whose proof would leave this machine is recorded unverified by a validator decorator rather than proven anyway. LEMONFIBER_OFFLINE is all six at once, because an operator who wants nothing to leave wants nothing to, and asking them to find six settings is asking them to miss one. The sixth is the only one that reaches a person rather than a service — it carries the reason a request was turned down to the household member who asked, at the address they already gave the request service — and it was added the way that file says a sixth has to be: as a decision somebody takes and answers the four questions for, with the two addresses it may reach declared in the list itself and handed to the sender, so a message cannot go anywhere the operator reading the list was not told about. The stack’s own requests are listed as the stack’s — every service the manifest declares has an entry saying what it reaches and what for, including the three that reach nothing, so a service arriving is red until somebody writes down what it talks to. And the claim is watched, not only read: a full non-disruptive diagnosis runs through a recording transport, and every request that left the machine must be one this enumeration would have told the operator about. It was proven by planting a beacon in a check, where every source sweep still passed.
What is kept on this machine, and taking it off G8-R7, G8-R8 stored/, app/stored.rs, lemonfiber stored and lemonfiber forget · /api/stored and the forget action · the dashboard’s questions and errands. Every location was written down already — Paths is the layout and each accessor carries a sentence saying what it is for — and the sentence was a doc comment, which a contributor reads and an operator cannot. So the why is declared in words somebody can act on, one per place, with the ones holding a credential marked as such, and a guard reads the layout’s own source at compile time and holds the two lists to each other in both directions: a place the layout gains and this does not is red, and an entry for a place the layout no longer has is red too — it would send somebody looking for a file that is not on their machine. Removal is two directories, because everything the layout names sits under one of them and the layout’s own tests already hold that; all locally stored lemonfiber data is therefore a claim about two paths rather than a list somebody keeps in step with fourteen, and it is asserted again where it is relied on. Nothing goes without the agreement, a rehearsal is not one, and what is agreed to is the listing itself rather than a summary of it. A directory that will not go is named with what the machine said about it — being told everything was removed and finding one still there is being told something false. And what is not lemonfiber’s is named in the same answer: the library and the downloads are the operator’s, the containers are the engine’s, and an operator agreeing to this is entitled to have read that before they agree rather than afterwards. Removal is a port of its own beside Volume rather than a method on the wide filesystem trait, for the reason that one is: the command needs nothing else of a filesystem, and this is the one operation here that cannot be undone.
What actually left, written down as it went G8-R14 The enumeration beside it says what would be sent — which requests exist, where each goes, what travels and how to stop it. That is a description of the program, and an operator asking whether they were told the truth needs the other thing: a record of what actually went, made as it went. recording.rs is a transport decorator rather than a call at each site, for the reason the retry policy beside it is — a record kept at fifteen call sites is fifteen records, and the request that goes unrecorded will be the one at the site somebody forgot. It wraps outside whatever is already there, so three attempts at one request are three lines: what left, rather than what a caller asked for. What is written down is deliberately less than what was sent. A request carries the credential a service authenticates with and, where it writes, a body; a log holding either would be the thing this requirement exists to prevent — an operator who switched every outbound request off would still be left with a file full of their own keys. So a line is when, what kind of request, where it went with the query and any userinfo taken off, and what came back. Never a header, never a body. The query goes because that is where a search term, a title or an indexer key ends up, and the address goes through the same scrubber a support bundle’s does, so a credential that reached a URL in spite of all this is withheld here too rather than only where somebody remembered. A request nothing answered is recorded as one, because a record of only what succeeded is a record of a quieter machine than the real one. It is kept in outbound.log beside the settings, bounded at five hundred lines with the oldest dropped — a record that grows without end is a disk problem somebody meets months later, and a trimmed one still answers “what has this been doing”, which is the question. Two things are held rather than assumed: a machine that will not say where its own files go writes nothing and pretends nothing was written, and a record that could not be written never fails the request — telling an operator their run stopped because a log was unwritable would be this requirement getting in the way of the product it exists to make trustworthy. The claim is driven through the real decorator rather than by calling the writer, because a recorder nothing is wrapped in records nothing and would pass a test of the writer.
Somebody being asked to join is told the operator can see what they watch G8-R13 The operator of this stack can see what everybody watches — the media server keeps that and shows it to whoever administers it. The disclosure is owed to the household member, not the operator, which makes where it is said the whole of the requirement: it belongs at the moment somebody is being asked to join, because telling them afterwards is telling them once they have already put their watching on a machine somebody else administers. So it is a line of the invitation itself, in render/invitation.rs, addressed to the person who will send the message and phrased as the thing to pass on — lemonfiber never speaks to the recipient directly, so the only way it reaches them is by being handed to somebody who will. Said plainly and without softening, and held by a test that reads the rendered invitation rather than the constant, because a sentence moved out of the message still passes an assertion about the string.
What the privacy stance still owes G8-R4, G8-R10 There is no update check and no updater: install-updater = false, and nothing in this repository asks any version endpoint anything, so G8-R4 and G8-R10 have no subject rather than a met one and are not ticked. What holds them until there is one is the pair of guards above — a version endpoint arriving in the source is red against the host list, and one arriving in the enumeration has to declare what it sends. None of this is A6: a clean uninstall asks for tiers, a manifest with sizes, containers and images removed, media refused by default and behind its own confirmation, and a list of what lemonfiber cannot remove — thirteen requirements, and what is built here meets the one G8 asks for.
The app the browser is served, and the version it speaks ARCH-R54 assets/web is a submodule pinned at built-v0.1.0, which is lemonfiber-web’s built tree rather than its source: publish.yml fires on a version tag, builds, and force-pushes the output to a built-<tag> tag of its own, so what the binary carries is addressable as a git revision and verifiable by content rather than fetched at build time. The pin is an exact revision, never a branch. It is compiled in with include_dir!, exactly as the stack beside it is, and served — a browser asking for the root gets that tree’s own index.html and its assets, which was checked by running the binary and fetching them rather than by reading the type. That last part was missed once and is worth recording: the submodule and the version check landed in v0.10.0 while EMBEDDED_APP was still None, so the app was pinned, validated and not served, and a release note said otherwise until it was corrected. The requirement was met throughout — it asks that the declared version be validated at build time, not that anything be served — which is exactly why nothing went red. The check has something to read. The app declares its wire version in app.json and build.rs compares it against the contract’s, refusing the build when they differ and naming both. It was watched refusing: the pinned app’s declaration changed to 2 stopped the build with the app says 2 and this binary serves 1, pointing at the file and offering both ways out. Shipping the pair unchecked would put a browser in front of a server answering it in another language, and the failure would appear in somebody’s house rather than here. An absent app is still not a mismatch. A build whose submodule is not populated carries an empty directory rather than failing, so the repository can be worked in without it; the endpoints below /api answer as usual, and lemonfiber ui --assets <path> serves a directory instead, which is what somebody building the app itself needs.
The disk accounted for, and exhaustion seen coming D5-R1, D5-R2, D5-R3, D5-R4, D5-R5, D5-R7, D5-R8, D5-R9, D5-R10, D5-R11, D5-R12, D5-R13, D5-R14 lemonfiber space, /api/space and the errand of the same name answer one reckoning, in space/. Every figure in it counts underlying files rather than names (D5-R2): on a stack whose imports hardlink, a file lives in the downloads tree and in the library at once, and adding the two listings together reports twice what is there — which makes the total wrong, the trend wrong, and every cleanup suggestion built on them wrong. Counting charges each underlying file the first time it is met and is held across the whole walk rather than restarted per tree, so a shared file is paid for once by the report and not once by each tree in it. Both readings are kept and reported apart wherever they differ (D5-R3), because what a tree would take unshared is what somebody is quoted when they think about moving it and what it occupies is what the volume has lost. Exhaustion is projected rather than reported (D5-R1): Level::reached reads the middle steps off free space minus what the download clients still have to write, so forty gigabytes free against a sixty-gigabyte queue reads as critical rather than comfortable. The steps escalate — ample, advisory, warning, critical, exhausted — and only the last acts on its own (D5-R7): admits turns it into a refusal in one place, and the upgrade command, which re-fetches a whole library and is the largest acquisition this product can start, goes through it. The top step is read off what is free now rather than off the projection, because a database cannot write into space a queue has not consumed yet and halting a working stack on a prediction would stop work that still fits. Both volumes are measured (D5-R8), the data location and the directory the services keep their own databases in, each saying what its filling costs — one loses half an import, the other corrupts a database — and they are told apart or together by mount point rather than by path, which StorageFacts now carries. That mount is also what makes the limit the effective one (D5-R11): pick attributes a path to the longest mount containing it, so a dataset given a quota inside a far larger device reports the quota, and a test drives exactly that arrangement. A reading taken across a network share carries the moment it was taken (D5-R9), because nothing here can make such a figure fresher and presenting it as live is the one thing worse than presenting it late. Nothing is kept between runs, so space freed by hand clears the condition on the next reading rather than on a re-check somebody has to know to ask for (D5-R10). Consumption is attributed (D5-R4) one line per directory beneath the data location — which is how several libraries on one volume are told apart — plus the services’ own files and what is still to land, and beside it a second reading of the same bytes saying what could be got back and what each would cost. The hardlink evidence is what tells an orphan from a healthy seed (waste.rs), which is the distinction app/queue.rs says in as many words that nothing could make: an import makes a second name, so a completed download whose files carry one name was never taken by anything and removing it loses nothing, while one carrying two was imported and is seeding. A download the walk could not match to any file is left out entirely, because “I could not find it” and “nothing points at it” must not read alike when one of them is a reason to delete something. Nothing is removed without an answer, at any level of fullness (D5-R5): a reading removes nothing whatever it found, a rehearsal says what would go and takes nothing, and a confirmed run takes only the downloads nothing ever imported and the archive parts already unpacked beside their contents. A file far out of line with the rest of this operator’s own files is pointed at (D5-R12) — compared against the middle file rather than the mean, since one enormous file drags a mean towards itself, with an absolute floor beneath it so a tidy collection of small files never reports its largest as a fault. An import that has stopped moving is named with what is on disk for it and the cleanup is offered above the retry rather than after it (D5-R13). And what the operator has already told the queue check to leave alone is never named as waste (D5-R14): it is reported as its own line, so the room it takes is still accounted for, and it is excluded from what any answer can take. Two new seams: Occupancy walks a tree and reports one entry per file with its identity, so the counting stays above the port where a fake drives every case of it, and Seeding reads what a torrent client still holds, with the ratio worked out from the byte counts rather than read off the float beside them.
Removing a torrent that is still seeding D5-R6 lemonfiber stop-seeding, the action of the same name and the errand on the dashboard ask the download client to let one completed download go, files and all — the first thing this product asks a download client to destroy, and it is deliberately the narrowest destructive request in the binary. It is its own request, apart from the account beside it (D5-R6), because the spec’s edge case forbids bundling a torrent’s removal with generic cleanup: space --confirm still takes only what costs nothing, has no argument that could name one of these, and cannot reach this path at all. It states the consequence before it asks anything. An unanswered run reports where that download stands, what it occupies, the ratio it is still earning, what a private tracker does about an account that stops earning one, and what goes with it — the copy in the downloads tree, which is where the room comes back from — and what stays, which is any name the library holds of its own. Removing it does not touch that name, and saying so is what stops the offer reading as somebody’s library being taken. And the agreement is its own. space/letting.rs names each offer over what it said — which download, what it occupies, where it stands, the ratio, and the consequence — through the same agreement a repair and a restore are answered by, and every one of these names begins with a word for this errand, so an answer given to the general cleanup can never name one of them and a test holds that in both directions. There is no blanket form of the yes. The command carries no confirm at all and TAKES_AGREEMENT leaves it out on purpose, so the argument list refuses one by name: the only way to reach the removal is to say back the name a run that stated the cost printed. An answer given for a reading that has moved since — a ratio earned while somebody was deciding — is refused as another offer rather than spent, and a rehearsal says what would go and asks the client nothing. The write itself is Qbittorrent::stop_seeding: it looks the torrent’s hash up by the name both sides use, from a listing taken at the moment it acts rather than the one somebody read, refuses two torrents of one name rather than choosing between them, and reads the listing back afterwards — a removal reported as done while the client goes on holding the torrent would record a ratio as lost while it is still being earned. Four refusals of its own under this feature’s prefix: nothing here holds downloads at all (SPACE-4), the client answers and is holding nothing of that name (SPACE-5), the agreement names another reading (SPACE-6), and the client could not be reached or would not let it go (SPACE-7). A run that cannot measure the disk is refused before any of them, because a consequence nobody could state is not one anybody can agree to.
What a household may ask for, and what it may ask for now D7-R1, D7-R2, D7-R4, D7-R6, D7-R9, D7-R10, D7-R11, D7-R12, D7-R14 Everything here was read off ghcr.io/seerr-team/seerr:v3.3.0 rather than recalled, and four of the nine turned out to be the service’s own behaviour already — which is worth ticking only because it was checked, not because it was assumed. D7-R1 and D7-R2 are the two that needed building. A policy is one of three plain words — trusted, within-a-limit, everything-waits — and it is a reading of two settings rather than a fourth setting beside them: whether requests arrive unseen is a permission bit, and whether a period limits them is a pair of numbers, so a fourth stored value would be one able to disagree with the pair it describes. Per person is not a fourth policy either; it is one of the three chosen for one member, which is why every surface asks a policy and, separately, who it is for. A limit is --requests N --days D and reads back as “5 requests a week”. Nothing named is nothing changed: a run that gave a limit and no policy leaves the policy alone, which is why the setting in force is read before anything is written. Choosing to trust everybody lifts the limit rather than leaving one counting in the background — a household told nothing limits it while the service goes on counting is two answers to one question. D7-R6 is lemonfiber household approve N and … decline N --reason …, over the request service’s own POST /request/{id}/{status}, with the decision in the path; nobody opens the request service to answer anything. D7-R9, D7-R10, D7-R11 and D7-R12 are absences and behaviours, each verified in the image’s own handlers. Nothing anywhere carries a size for a request — no field, no setting, no endpoint — so nothing can retroactively cancel one over its size, and the estimate this program shows is produced at read time and never fed back (D7-R9). The quota is checked once, in the request constructor; the status-change handler re-checks nothing, and this program’s approve path checks the disk and not the count (D7-R10). A second live request for the same media is refused as a duplicate before the count is touched (D7-R11). Television is counted a season at a time and the seasons already available or already asked for are filtered out before the count is compared — so a partly-held series charges only what is missing (D7-R12). D7-R14 is enforced twice by the service and neither is this program’s doing: getQuota forces the limit to nought for anybody holding MANAGE_USERS, and POST /user/{id}/settings/main writes the four quota fields only where the account holds no MANAGE_USERS and is not the account asking — so the owner this program signs in as cannot be rationed by it even by mistake. One defect was found by reading that same handler: it assigns every field it reads off the body, username and the locale among them, so a body carrying only the four figures would blank a member’s own name on its way to setting a number. The adapter reads the document, changes the four, and writes it back whole. Built as asking/ for the vocabulary, seerr/asking.rs over a new Approving port, app/asking.rs for the two writes, and app/household/allowance.rs for the reading half — which arrives on the household read that was already there, because what a household may ask for is part of who is in the household and not a fourth endpoint a browser would have to know to ask for.
What a thing costs, said where somebody is deciding what to ask for D7-R3 The request service has no notion of size and still has none — no field, no setting and no endpoint carries bytes, re-read off ghcr.io/seerr-team/seerr:v3.3.0 rather than recalled. What it does have is a home page built of rows, each row a heading over a strip of things to ask for, and an administrator may add a row and title it freely while anybody signed in may read the listGET /api/v1/settings/discover is registered before the gate the rest of the settings sit behind, and the writes are not. That asymmetry is the whole opening, and it is the one thing about this that had never been looked for: the household needs no account here to be told something, because the account they already have on the request service is the account that reads it. A row whose search matches nothing still draws its heading, so a heading is a line of this program’s own text standing at the top of the page every member lands on, above everything they might ask for. Driven against the image — an owner, a member holding only the request permission, the notice written as the owner and read in a browser as the member. So the estimate is now in front of the person choosing, which is where the requirement always put it and where the figure changes a mind: the difference between five gigabytes and forty is a different choice, and beside an approval it is only a receipt. It is the same figure the operator is handed, from the same allowance::for_kind — two tables would disagree the first time a preset was retuned, and the place they would disagree is what somebody is told a thing costs against what is then reserved for it. A heading is one clipped line and the wording is cut to fit it. truncate at twenty-four point bold holds about seventy characters at a full window and half that on a telephone, measured against the image rather than guessed — so the notice leads with the two figures and lets the qualification be the half that is lost, while the message handed to the operator has no width to fit and still says the whole thing. What it cannot be is per-title: nothing in this stack knows what one particular film will take, so the estimate this product has is the estimate it gives. Built as service/notices.rs for the port, seerr/notices.rs over the row list, and household/notices.rs for the words — hung on the way past the household reading, which is the one moment the quality in force and the disk are both in hand.
A limit stated before submission, and a period that says how it frees up D7-R5 Most of this is the request service’s own behaviour, and it is ticked because it was checked rather than because it was assumed. Driven against ghcr.io/seerr-team/seerr:v3.3.0 in a browser, signed in as a member holding only the request permission: with the two figures this program already writes in place, the request dialogue says “2 movie requests remaining” before anything is submitted and opens to “You are allowed to request 2 movies every 7 days”; at nought it says “No movie requests remaining” and the submit button is disabled. The server refuses independently — 403 {"message": "Movie Quota exceeded."} — so the block is real and not only a greyed button. That is blocked at submission, stating the limit, and it is in force the moment lemonfiber household allow --requests N --days D is run, because the dialogue draws the two figures this program writes. What was missing was the reset, and the reason it was missing is that the service states the window’s length and lets it be read as a reset. getQuota in /app/dist/entity/User.js counts what was asked for since now less the period and answers with the period, the limit, the spend and what is left — no date, because the window rolls: a request stops counting seven days after it was made rather than everybody’s count clearing together. The whole of what the service says about this is two sentences in /app/src/i18n/locale/en.jsonYou are allowed to request {limit} {type} every {days} days and {n} requests remaining — and the first of them reads as a day of the week that comes round. So somebody who has run out waits for a reset that never happens, which is the outcome this requirement exists to prevent. The date is one person’s and the shape is everybody’s, and they are now said in the two places that fit them. The date reaches the member it belongs to through the message handing_over writes in the second person — there is room again from 2026-08-24, computed by window::frees_up from their own earliest counted request. The shape goes on the one surface the household reads before they ask, beside what a thing costs and on the same pass: Room returns one at a time, as your old requests age out, in notices.rs. It names no figure on purpose — the service already states how many and over how long and states them per person, so a house whose default is a week carries members held to a month, and a number on a line the whole house reads would be the wrong number for exactly those people. It is shown only where anything here is counted over a period at all, the household’s own setting or any one member’s, read off what actually holds each of them rather than off the house’s default. And a full disk takes its place rather than standing beside it: nothing is fetched while there is no room, so how a limit frees up is not the question in front of anybody, and the two lines together read as the one thing the disk’s own sentence exists to deny. Held to the same measured widths as the notices beside it — seventy characters at a wide window, twenty-eight at the width of a telephone, with Room returns one at a time complete inside the twenty-eight — and guarded by the same tests.
A refusal that requires a reason, and the reason reaching the person who asked D7-R7 The half that is stronger than the requirement is unchanged: a decision is LetThrough or TurnedDown { reason }, so a refusal cannot be constructed without one at all, a reason that is only whitespace is refused in its own right, and an approval carrying one is refused by name on the way in. The words still survive the decision — reasons.rs holds the record, refusals.json holds it beside the settings so a backup carries it, and it is pruned against the service’s own list rather than by age. What is new is the second half of the requirement, and it came of a decision rather than of a discovery. Three passes over the request service had found the same thing and were right to stop where they did: POST /request/{id}/{decision} reads no body, MediaRequest has no column for a reason, the declined notification’s text is the media’s own overview, and the per-person channel GET and POST /api/v1/user/{id}/settings/notifications carries pgpKey, discordIds, the two Pushover tokens, the Pushbullet token, telegramChatId and a per-agent switch — every one of them an address or a switch and not one of them a sentence. So nothing this program says can travel through the request service, and that is now checked three times over. It does not travel through it. It goes to the address the member already gave it. outbound.rs says a sixth entry is a decision somebody makes by adding a variant and answering four questions about it, and that decision was taken: Reach::Household, switched off by LEMONFIBER_REACH_HOUSEHOLD, listed by lemonfiber outbound, /api/outbound and the dashboard with what it is for, exactly what travels, whether this machine allows it and what stops if it does not. Two of the service’s six kinds of address and no more. A Discord identifier needs a bot sharing a server with the person, a Telegram chat needs the bot it was opened with, an address for electronic mail needs a mail server, and a browser subscription needs the key it was subscribed under and a content-encryption implementation — four things this program does not hold. Pushover and Pushbullet are the two whose whole address is what the member typed, and the requests are shaped as the service’s own agents shape theirs, read off /app/dist/lib/notifications/agents/pushover.js and pushbullet.js in ghcr.io/seerr-team/seerr:v3.3.0, so what arrives arrives in the place and the form that member already receives things in. B5-R1 is kept by carrying only what the service cannot. The message is the word Why and the operator’s own sentence. The decline itself is never re-sent — the service sends that — and no name of this product, no address to open and nothing to sign in to goes with it, so D4-R9 stands: a message arrives where they already receive messages and tells them nothing about an account they do not have. And it goes only where the request service itself would go. The member’s per-agent switch is read and honoured, which is what stops a message reaching somebody who turned that agent off. A belief this row carried is corrected here: hasNotificationType answering true for a type of nought is about the event being nought and never about the member’s own mask — it adds the test notification’s bit to a mask that lacks it and then compares, so a member who has never chosen has nought for every push agent and the service sends them nothing there. What defaults to everything is email and webpush, whose stored default is ALL_NOTIFICATIONS, and that is why the bare declined reaches them. So the limit is stated rather than glossed: the reason reaches a member who asked for refusals on Pushover or Pushbullet, and nobody else. B5-R5 is kept by a record rather than by care. refusals.json gains told, written whether anything was reached or not, and the sending happens only where it is absent — so a member with nowhere to send to is asked about once and never again, and a run started twice reads the same file. Every outcome is a line under the decision, because a refusal answered with silence about its own delivery reads as delivered: told why and on which service, nowhere to send, the setting is off, where they are reached could not be read, or the service would not take it — and the last four all end in the words being yours to pass on. Built as service/addressing.rs for the port, seerr/addressing.rs for the read, telling.rs for what travels, and passing_on.rs for what becomes of the words. The two addresses it may reach are declared in the enumeration and handed to the sender, so a message cannot go anywhere an operator reading that list was not told about, and the test reads the list rather than a literal beside it.
A reminder that is built, and an expiry the household agrees to in advance D7-R8 The reminder is unchanged and built: one line under the household list naming whose requests are overdue and how long the oldest has waited, one per person rather than one per request, at a threshold of a week. What was missing was the clock, and this repository now has one — its second long-running thing, and the first that acts while nobody is watching. It is a second one rather than an extension of the watch, and the reason is not the interval. A watch ends: it returns the moment the data root is lost, which is its whole contract, so an expiry folded into it would stop the day a drive was unplugged. And an operator who started a guard on a drive has agreed to guard a drive — making their household’s requests close as a side effect of that is the same silent policy this goal has refused three times, one level up. So the agreement is its own act. The period is named and never defaulted, which is the whole of what makes this something the household arranged rather than something that happened to it: a run asked to begin against no arrangement is refused by name (QUOTA-8) instead of being given a figure this program chose, and a period shorter than the reminder is refused too (QUOTA-9) — an expiry sooner than the reminder is one nothing is ever reminded about, so what an operator would watch is requests disappearing rather than requests waiting on them. Arranging it and running it are two acts, and that is what lets the household be told in advance rather than afterwards: lemonfiber household expiring --after 30 writes the arrangement beside the settings and stops, and from that moment the reminder names the period and the message each member is handed stops promising that nothing ends their wait. --never takes it back. Every sentence says whether anything runs it, and while nothing does the sentence is unchanged: lemonfiber household expiring holds the terminal it is started in, and the reminder reads closed after 30 days while lemonfiber household expiring is running, and nothing runs it for you — which is the one sentence a household believing otherwise would be missing. That was the whole of it when this row was written, because B8 brings the stack back after a reboot and hosts nothing of lemonfiber’s own, so a clock outliving a terminal was something no requirement had asked for. One does now, and it is a separate feature with a row of its own further down; what this row claims is unchanged by it. The clock is the same clock, the arrangement is the same act, and the sentence still refuses to promise a background nothing is providing — it now says which of the two is true rather than only ever the worse one. What ends a run is the arrangement, not a signal. The record is re-read on every wake and any disagreement stops it, because a run holding a household to a period since withdrawn or replaced is applying one nobody currently agrees to — and it is also how a second run replaces a first. B5-R1 is kept exactly where D7-R7 keeps it: the closure goes through the same door a refusal does, so what travels is the word Why and one sentence — nobody ruled on it within 30 days; ask again if you still want it — and never the decline, which the request service sends itself. It says to ask again because that is the difference between this and a refusal: an operator who turned something down decided something, and a request that ran out was never decided at all. B5-R5 is kept by the record D7-R7 built, with one line added to it. The words go only where told is absent, as before — but a closure now leaves an existing record exactly as it is rather than writing a fresh one, which an operator’s own second answer still does. That is the difference between the two: a second answer is a second decision and owes its own words, and a second closure is the same sentence about the same silence. Without that line the record would have been rewritten on each wake and the words owed afresh every hour, and what would have stopped a household hearing it daily is the request service having moved the request out of reach — which is care taken elsewhere rather than a record here, and is exactly what this rule refuses to rest on. What expired is readable afterwards and readable as an expiry. refusals.json gains expired, defaulting to false, which is the correct reading of every record written before a household could arrange this; the household reading carries it per request beside the reason, the day and who was told; and the member is handed Closed unanswered on 2026-09-16 rather than Turned down, because being refused and having run out are different things to have happened to somebody. That is the spec’s own per-request expired state, which the request service has no field for. The run’s own answer is the household, the way a decision and a limit are answered, with a count over it rather than a line per closure — a run left going for a month would otherwise end in a summary nobody could read, and each closure is on the record as it happens, so an interrupted run loses the summary and none of the substance. One thing is short and it is declared rather than left to be noticed: the running half reaches neither the browser nor the screen, because both hold a long thing only while somebody attends to it and a period measured in days outlives any tab — a clock that stopped every afternoon would be worse than one nobody offered. The household row of the parity table is partial on both columns for that one reason, with what would close it named. Built as asking/expiry.rs for the arrangement, app/arrangement.rs for where it lives, and app/expiring.rs for the clock — over the sender and the record D7-R7 left in place, both reused rather than rebuilt.
A full disk that stops the asking, and says so on the page they ask from D7-R13 The distinction the requirement is about was never the gap; the block under one policy was. A disk with no room left refuses in the disk’s own words and under its own code, from admits — the one reading of the volumes every command that brings content onto the disk shares — and never as somebody’s limit. An operator who read a full disk as a quota would go and raise a quota and watch the same refusal happen again, and the two differ in the sentence, in the code and in the module. What is new is that the reason now reaches the requester before they ask, in the request service’s own page rather than through the operator. Where admits says there is no room, a row is hung at the top of the page every member lands on saying so as the disk and ruling the limit out by name — because rolling a period over is the one move a member would otherwise make, and it is the one that cannot help. It is taken down again the moment there is room, because a notice left standing after it stopped being true is worse than one never shown. What was still open was one policy, and the service’s own gate closes it. Under everything-waits every request meets admits at the approval; under trusted — this feature’s own default — the request never passed through this program at all and nothing stopped it. It does now: where admits says there is no room, holding.rs takes away what lets each member ask, on the same reading that hangs the notice — so the button that is gone and the heading explaining it are written in one pass and neither can outlive the other. Six ways to ask, not one. A film is refused unless the account holds REQUEST or REQUEST_MOVIE, a series unless it holds REQUEST or REQUEST_TV, and the higher quality has three more of its own — read off the gate in /app/dist/entity/MediaRequest.js inside ghcr.io/seerr-team/seerr:v3.3.0 rather than off the names in the table beside it, because taking only the plain one, which is what this row assumed last time and what “the request permission” sounds like, leaves a household still asking through any of the other five. The watchlist files through the same call, so what comes off stops a request nobody typed as well as one somebody did. Why the permission and not a quota is the requirement’s own word. The service refuses in two different sentences — Movie Quota exceeded. against You do not have permission to make movie requests., both 403, both read off /app/dist/routes/request.js — so what a member meets already says which of the two it is; and a limit of nought is not a state this service can be put into at all, while one that could would refuse in the words of a limit and send somebody off to roll a period over, which is the one move that cannot help. It is no longer the silent block this row refused. GET /settings/discover sits before the administrator gate and the page itself is gated on nothing — only its customise control asks for ADMIN — so a member with no way left to ask still reads No room on the disk — not your limit, so nothing new is fetched at the top of the page they land on. What was taken is written down, because giving it back is not a grant. What a household may ask for is the operator’s to decide, so held-back.json beside the settings keeps the number that came off each account and the release puts back exactly that: somebody who could only ask for the higher quality gets that back rather than a plain grant this side decided was equivalent, and a permission an operator narrowed meanwhile stays narrowed. The owner is left alone, and for two reasons rather than one: the request gate reads ADMIN first and answers yes whatever else is set, so a bit taken off one would block nothing — and the write refuses regardless. POST /user/{id}/settings/permissions answers 403 for the account it files first and for the account asking, which in this stack are the same account and are the one lemonfiber signs in as, so a reading that tried would report the same failure on every glance at the household for ever. That is not something documentation says; it is in the handler, and it is why the owner is passed over before the write rather than after it. Two things it does not do, and both are the notice’s own price. It lets go on the reading it holds on and on no other, because this program has no clock: a house whose disk fills and empties again while nobody reads the household stays held back until somebody does. And the sentence is on the page they land on rather than at the click, because the affordance at the title is simply gone — which is exactly why the two go out in one pass and why neither ships without the other.
The line accounted for, and the stack held to a share of it D10-R1, D10-R2, D10-R4, D10-R5, D10-R7, D10-R8, D10-R10, D10-R11, D10-R12, D10-R13, D10-R14 lemonfiber bandwidth, /api/bandwidth and the action of the same name answer one account of the line, in bandwidth/. Two more seams follow the one the row above openedThrottling, which sets and reads a client’s own rate limits, beside a Metering that asks what one has moved — and both are read back the way stop_seeding is, for the same reason: a client that answered a write and did not apply it looks exactly like one that did. A limit is expressible as a share of the line (D10-R1): almost nobody knows their connection in bytes a second and everybody knows they want the stack to have about half of it in the evening, so Limit carries a proportion or a figure, and a proportion never appears without the measured figure it is a proportion of (D10-R2) — the sentence is built once beside the limit rather than in each of three surfaces, and a test reads the measured line out of it. A share of a line nothing has measured is its own answer rather than folding into “unlimited”, because “half of an unknown number” resolving to “no limit at all” is the shape of a setting an operator believes is in force while the stack takes the whole line; it is refused (RATE-1) with the two ways out. Nothing here runs a speed test: the figure is one the operator declared or the fastest the stack has been seen to move, raised as a high-water mark and taken only from a client nothing was holding back — a rate measured under a limit is a measurement of the limit, and recording it would talk a throttled stack down to a tenth of its own connection and then to a tenth of that. A reading through the tunnel says so, and one that has stood a month says how long. The upload is declared apart and is never less careful than the download (D10-R4): a saturated uplink degrades everything the line carries, downloads included, because the acknowledgements that keep a download moving cannot get out past the queue of upload data — so where a download limit is asked for and nothing has ever been said about the upload, the upload takes the smaller of the two shares, held by a test that walks every share from one to a hundred. What that costs seeding is read off the limit rather than set beside it (D10-R5), so a throttled upload cannot be reported without it: throttling is offered in preference to stopping precisely because a slow seed still earns ratio and a stopped one does not. The household’s hours are wall-clock and are kept by the clients (D10-R7): Rhythm carries hours and minutes and refuses a time bearing a zone or an offset on the way in, the same reading instant.rs makes of a stamp in a frame it cannot place — a window stored against an offset is the one that moves an hour twice a year and either skips its boundary or applies it twice. Nothing in this product knows the household’s local time of day, so the window is written into the client’s own scheduler and the client’s clock is the household’s, which the stack sets from TZ; applying a schedule on a stack that names no zone is refused (RATE-2) rather than landing quiet hours in UTC. Which side of the boundary the stack is on is therefore read back from the client rather than worked out, which makes the reported period a measurement. A declared cap is counted against what the clients actually moved (D10-R8), warned about before it is spent, and never presented as the household’s total (D10-R10): the sentence saying what is not counted is attached by Metered’s only constructor, so a figure cannot reach a surface without it. The two clients count differently and the difference is carried rather than smoothed: the Usenet client keeps a figure per calendar day and answers for a month exactly, the torrent client keeps a running total since it last started, and a month nothing could count is no figure rather than a zero — a month reported as untouched on a stack whose clients would not answer is the one reading that would let a cap be passed in silence. An override cannot be asked for without an end (D10-R11): Respite has no constructor that does not take one and none that takes longer than an evening, and one that has run out is reported by the run that finds it and cleared by the same run, because an operator wondering why the download slowed down is owed the reason. Only lemonfiber’s own services are limited (D10-R12) and local playback never is (D10-R14): both are said on every report rather than left to be inferred, since the two fears an operator brings to a feature like this are that it throttles the household’s own viewing and that it meddles with the machine — and a new architecture guard refuses any traffic shaper by name in the shipped half of the tree, because a shaper reached from one line of one adapter reads like plumbing and changes what this program is. A limit is verified rather than assumed (D10-R13): every write is read back and the throughput is read beside it, and the two failures are kept apart — a client reporting a different figure did not take the setting and is fixed in its own configuration, a client reporting the right figure and moving past it took the setting and is not honouring it, and telling an operator “the limit is not working” for both would send them to the wrong place half the time. A rate bouncing either side of its figure is not an overrun, because a warning on an obedient client is how a report stops being read. A client that would not answer is its own line rather than an absence: an unknown limit rendered as no limit is a report reading better than the stack is.
The quiet half of the household’s day, and what happens at a cap D10-R3, D10-R6, D10-R9 The household’s day now reaches both download clients, and each keeps it in its own dialect. qBittorrent has two sets of rate limits and a scheduler that switches between them, so the active hours become its alternative limits inside the scheduled window and the quiet hours its ordinary ones outside it. SABnzbd has no window: its scheduler is a list of dated instruction lines, so the same day becomes two of them — the active rate at the hour the household gets up, no limit at the hour it stops — written by sabnzbd/scheduling.rs. Everything about how that is written was driven against lscr.io/linuxserver/sabnzbd:4.5.1 rather than read out of a document, and three things it found would each have shipped a silent defect. The API door that takes the whole schedule at once splits the value it is given on spaces unless it contains a comma, so one line handed to it becomes six nonsense lines — accepted, saved, and ignored by the scheduler afterwards. That same door never reloads the running scheduler, so a schedule written through it would not have taken effect until the container was next restarted. And the client’s own configuration pages, which do both correctly, take one line at a time: they append and they delete, which is what makes leaving the operator’s own lines alone the natural shape rather than a precaution. So lemonfiber adds and removes rate lines and reads the list back to confirm it, a run that wants what the client already holds writes nothing at all, and a nightly pause or a weekday resume the operator wrote is never so much as named — pinned by a test that puts two of theirs in and reads them out again. What is replaced is every rate line, because a household’s hours and an operator’s own speed schedule are one setting and two of them in one list is a window overridden at an hour nobody chose. That closes D10-R3 for both clients and D10-R6 with it: each line added or removed reloads the client’s scheduler, which re-evaluates the schedule against its own clock and applies the side of the day it is currently on, so the boundary is crossed on the current run rather than at the next one. Which side that is stays a measurement on both clients — read off the limit the client’s own scheduler has applied, never worked out from a clock this product does not have. What happens at a cap is now acted on, and the acting is a second write beside the rate rather than a rename of it. ports/service/fetching.rs is a port of its own because how fast a client may go and whether it goes at all are different questions, and a client held to a crawl is not a client that stopped. Stopped means both halves: nothing moving and nothing new starting. SABnzbd answers both with one flag — it pauses the downloader, so the queue fills and waits — while qBittorrent needs two writes, because stopping every torrent does nothing about the next release an *arr hands over within the hour; the preference that adds new torrents already stopped is written first, so there is no window in which one more download slips through. Its endpoint names came off the pinned 5.0.3 too: at that web API version the pair is stop and start, and the pause/resume a great deal of writing still names answers 404. throttle is a real crawl through the rate seam, never a raise — a limit already slower than the crawl keeps its own figure, and a share of a line nothing measured becomes the crawl rather than staying unlimited — and the report shows the figures that were handed over rather than the ones that were declared, off the same function, with a sentence saying which. Two rules make the acting safe to run every time. A spent cap turns a run that asked for nothing into one that writes, because the whole of what declaring a cap buys is that the answer was settled in the calm and is carried out when the month runs out rather than argued about then. And what lemonfiber stopped is the only thing lemonfiber starts again: stopping never consults the record, so a client somebody started by hand mid-month is stopped again, while starting always does, so an operator’s own pause survives a month turning over.

0.11.0 and 0.12.0. The request flow, one-account identity, approval quotas, parental controls, disk-space and bandwidth management, client-app guidance. Not started. See the spec roadmap.


0.13.0, 0.14.0 and 0.15.0. Reconfiguration, migration, uninstall, notifications, remote control, autostart & boot persistence, stack and self updates, rollback, and the service catalogue. Notifications are in, and so is part of the change journal 0.14.0 locks — that is recorded in the M4 table above, beside the seeding it was built for. Uninstall is in, in the row below, and so now are the two families 0.13.0 carries: every setup answer is individually revisable, and a setup already on the machine can be surveyed, adopted, stood beside, stood in place of, or carried across. Of the rest, nothing is started: no command updates the stack or itself, rolls back, or lists a service catalogue, and the web surface is loopback-only by design rather than remote control. Autostart is the one to be careful about: setup asks the question and takes a --autostart flag, and the wizard says in as many words that the answer has no configuration home and is collected but not written. An answered question is not a boot-persistent stack, and nothing here brings the stack back after a reboot. This repository does now name launchd and systemd, and it is worth being exact about what for: they are reached to keep lemonfiber’s own long-running commands going, which is B10 below and is a different subject from the stack coming back. Neither Docker Desktop’s login item nor a container restart policy is touched by it. See the spec roadmap.

Deliverable Spec Status Landing / notes
The refresh loop as the driver — and in-app delivery B5-R4, B5-R13, C7-R8 app/dashboard.rs’s refresh, app/screen.rs, app/outbox.rs. The condition store, the alert machinery and the queue check were all built and nothing ran any of them — each was reachable only from its own tests, which is why B5-R4 and B5-R13 were ticked for several releases without an implementation behind them. A refresh now drives all three off one store, loaded once and written once: three loads would be three pictures of the same history with the last write winning. The queue check reads the items the queue panel already fetched rather than asking each service a second time (C7-R8), so a stall reaches the operator without being sought. In-app delivery is unconditional and needs no configuration (B5-R4) because the screen is a channel that cannot be down — an alert is written to the outbox before any channel is tried, and the dashboard reads the outbox, so by the time delivery is attempted the operator can already see it. And the outbox is kept between runs (B5-R13): it was written to survive a channel that was refusing, and could not, because nothing wrote it anywhere — every run began empty, so an alert owed at shutdown was forgotten and a condition that resolved before anybody read it left no history. Two tests hold the pair of properties that matter for a screen refreshing once a second: something is said the first time, and nothing new is invented the second.
Notifications & alerting B5-R1, B5-R2, B5-R3, B5-R5, B5-R6, B5-R7, B5-R8, B5-R9, B5-R10, B5-R12, B5-R14 condition.rs, alert/, app/notify.rs (#194, #197–#199, #203, #204). Almost every rule here exists to not send something. A condition notifies once on onset and once on resolution, never per poll (B5-R5); a service flapping past three round trips is reported as flapping, which is a different fault with a different remedy, rather than as forty alternating states (B5-R6); everything at one moment is one digest rather than six interruptions (B5-R7). Four services failing the same way is one event naming all four (B5-R14) — grouped on the kind a condition carries beside its check, so the instance and the event stay distinct; the group is as loud as the worst thing in it, and the outbox marks every check it spoke for or the rest would be reported again next run. In-app delivery is the writing-down itself and so cannot fail, which is what lets a channel refuse without losing anything; a refusing channel raises a condition of its own and is deliberately never notified through channels — a notification about notifications failing, delivered by the thing that failed, is either a loop or a lie (B5-R10). A stack the operator stopped on purpose says nothing operational (B5-R12), but only while genuinely Stopped: an engine nobody could reach is not one somebody turned off. The request lifecycle is refused outright (B5-R1) — Seerr tells the requester itself, and a second message from here is what teaches an operator to mute the channel that also carries the leak. Setup asks appetite once, as three presets rather than a checklist of thirteen events (B5-R2), defaulting to the quiet one; every individual event stays switchable afterwards (B5-R3), kept in notifications.json. Alerts carry their remedies (B5-R8) and are redacted on the support bundle’s own rules (B5-R9); a critical one carries a whole digest through a quiet period rather than being split from its context (B5-R11); and a fault that came and went unseen is still in the history (B5-R13). One thing to read this row knowing: the only channel with an implementation is the in-app one — Screen, which cannot fail because writing it down is the delivery — and it is the only one any call site passes. There is no webhook, mail or push adapter, and the port’s own words call itself a design note rather than a seam anything crosses. Every rule above about a channel refusing is therefore proved against a fake, which is the right way to prove a rule and is not the same as having watched a real one refuse. No B5 requirement asks for a particular outbound channel, so the row is done as specified; what it is not is evidence that delivery works anywhere but the screen. One message now leaves for somebody who is not the operator, and both rules that bear on it are kept where it is sent. D7-R7 carries the reason a request was turned down to the household member who asked, at the address they already gave the request service. It is not a duplicate under B5-R1 because the decline itself is never re-sent — the request service sends that, and what goes from here is the word Why and the operator’s sentence, which is the one thing that service has nowhere to put. It is not repetition under B5-R5 because refusals.json records that the words were carried, written whether anything was reached or not, and the sending happens only where that record is absent. And it reaches a member only where the request service itself would reach them: their own per-agent switch is read and honoured, so an agent somebody turned off stays off.
Critical alerts bypass quiet hours B5-R11 alert/quiet.rs holds a window as two times of day and the zone they are read in — carried together, so nothing can ask whether an instant is inside it without saying whose evening it means. The zone is the one the stack already names in TZ and hands every container, falling back the way _common.yml does, because a window read in a different zone from the containers it is about would be quiet at the wrong hour. It is the only thing this product holds against a time of day; everything else is instants and calendar days, which is why the conversion is left to a timezone database rather than to arithmetic on an offset. app/notify.rs holds a digest inside the window only when nothing in it answers overrides_quiet() — a pair that existed and was tested but that no command reached until now. A held digest is written to the outbox first, so holding is delaying rather than dropping, and it is never split: delivering an emergency now and its context in the morning would be an emergency arriving without what it is about. The bypass is proven by removing the check and watching the test fail.
A read-only survey of what is already on this machine A5-R1, A5-R2, A5-R11, A5-R12 migration.rs assembles one survey out of standing, carrying, mode and image, none of which can reach an engine, and app/migration.rs does the reading; reached by lemonfiber migrate, GET /api/migration, and the terminal’s question list. Every existing project is reported with the ports it holds, every port lemonfiber would want that something else already answers on is named on both sides, and a service lemonfiber does not run is named rather than passed over — as is a service it does run that was started outside Compose. An engine or manifest that would not answer reports that it could not look, which is deliberately not the same answer as an empty machine. Nothing is started, stopped, moved, or written.
What no migration carries across, and what an import left behind A5-R8 Two lists, because there are two kinds of thing an operator would otherwise find missing weeks later. migration/carrying.rs’s NEVER_CARRIED states the four that transfer in no mode — custom formats, per-indexer tuning, connect scripts, watch history — with why each does not, and the survey carries them whether or not anything was found. migration/importing.rs names the ones a particular import could not take: a record following a quality profile this stack does not have is refused by name rather than carried to be re-graded, and a service unreachable on either side is named rather than passed over. Both leave a non-zero exit, so a script cannot read a half-carried library as a whole one.
An existing *arr database is never opened by an older binary A5-R6, A5-R7 migration/version.rs orders two image tags, answering Untellable rather than guessing where either is not a run of numbers, and migration/carrying.rs turns that into a verdict per service. app/adopt.rs is what acts on it: a database a later version wrote refuses the whole adoption, naming the service and both versions, and exits VALIDATION so a script can tell it from a failure. An upgrade — and a pair of versions that cannot be ordered, which takes the same side — will not proceed unconfirmed; unconfirmed it names the services whose databases would be upgraded and the host paths their data sits in, taken from what the engine reports being mounted, so the backup is of exactly the right thing. Adopting writes one line, LEMONFIBER_PROJECT, and a guard asserts it cannot reach the eraser, the volume, or the runner, so a run that fails or is abandoned leaves the stack that was there.
Four modes, adopt already chosen and replace never A5-R3, A5-R5, A5-R9 migration/mode.rs holds the four and what each would come to, offered least destructive first; Mode::default() is adopt and is the only one preselected() answers true for, asserted by collecting every preselected mode rather than by asking about replace. All four are built: adopt records which project lemonfiber manages, beside writes a Compose file layered over the stack’s own saying where each service listens instead, replace stops the existing containers by the ids the survey read from the engine and deletes none of them, and import carries the operator’s own records across through each service’s API. Every one of them refuses a project holding nothing lemonfiber runs — one_setup() is the single rule all four read — so somebody’s database is never adopted, stopped, or read for happening to be the only thing here, and each says what it would do before it does it. Guards in what_a_migration_may_reach.rs hold the halves to different limits: a survey may only read; adopting, standing beside and importing may write lemonfiber’s own configuration and services and nothing else; replacing may stop and may never delete. Each was driven red before it was trusted.
A migration never touches the media it found A5-R4 A guard rather than a line of code, because the requirement is a prohibition: nothing_in_a_migration_reaches_what_could_change_what_it_found in architecture.rs holds every source under a migration path to the seams a read needs — the engine, what it has pulled, the manifest, settings, and storage — and fails on any other. Written as what a migration may reach rather than what it may not: a list of forbidden names is a list of the ways somebody already thought of, and this is about the day somebody makes a survey helpful. storage is the filesystem held as a narrower trait that offers no way to write, remove, or link, so what began as an exception for describe is now the type system’s job and the rule has none. The guard was driven red before it was trusted, and again after the trait was split.
An existing layout that breaks hardlinks, costed and left alone A5-R10 migration/linking.rs reads the host paths each existing container mounts — added to Container and taken from what the engine reports, not from a compose file — and groups them by the filesystem they land on. Data on more than one filesystem, or on one that cannot hold a hardlink at all, is reported with the reason naming the filesystems and the cost in room: every import copies instead of naming the file twice, and how much room is left where there is least of it. The remedy is offered beside it and marked forced: false, with the survey saying in as many words that lemonfiber will not move anything to do it. A layout that links is not mentioned at all.
Clean uninstall — four removals, each explicitly chosen A6-R1, A6-R2, A6-R3, A6-R4, A6-R5, A6-R6, A6-R7, A6-R8, A6-R9, A6-R10, A6-R11, A6-R12, A6-R13 uninstall.rs for the vocabulary and app/uninstall.rs for the run. Tier is one value and never a set (uninstall/tier.rs), and takes_media is true of exactly one of the four — asserted, so no later change can bundle the library into another removal (A6-R1). Nothing under the data location appears in any manifest below the fourth, which a test sweeps all three for (A6-R3). The manifest is the list, not a count: one Item per container, network, image and path, each with what it occupies and, where it is being left, why — assembled in app/uninstall/lines.rs from what app/uninstall/gathering.rs read, with the total summed over only the lines that go (A6-R2). The library takes an agreement of its own: naming builds a CRC over the tier, every line and whether it goes, what is beside the library, the volume, and the size in the words it was shown in, so a bare --confirm is refused with the size stated and a yes given against one reading cannot be spent on another (A6-R4). Credentials are named as destroyed rather than left to be inferred — read off the manifest’s own secret lines, so what is said to have gone is what was said would go (A6-R5), and the removal that takes them says in the same breath that it takes the accounts everybody in the house signs in with and everything each of them has watched, because the media server’s own database sits under the directory that goes. What an uninstall leaves behind is listed with the way off it on this platform, and looked for rather than assumed (uninstall/outside.rs); a test holds every entry to a removal instruction on all five environments and refuses one that hands a macOS operator a Windows sentence (A6-R6). Nothing here refuses over a broken machine: an unreachable daemon, an unreadable stack description and a configuration home that will not resolve are each recorded in Confidence in the words of whatever refused, and the run goes on — the paths that could not be confirmed are named at the locations this platform conventionally uses and every one of them is kept, so nothing is removed from a guess (A6-R7). What could not be removed is enumerated with what the machine said and the command that finishes it, Removal::Partial rather than a silent skip (A6-R8). Files the stack did not write stop the blanket deletion (uninstall/foreign.rs): the stack’s own tree is downloads plus one directory per declared media type, everything else beneath the data location is a finding, and while there is one the data location is not on the list as a tree at all — only the directories the stack itself wrote are offered, one at a time (A6-R9). Images shared with another project are identified and kept, which needed a fact only the daemon has: a new Images port beside the engine (ports/docker.rs) lists what was pulled with the Compose projects standing on each, correlated by image id and by the name a container was started from (adapters/docker/images.rs) — and a container under no project counts, because something started by hand is as broken by a removal as another stack (A6-R10). What is still coming down is read for every removal and --wait lets it finish first, narrated as it waits (A6-R11). A backup is offered before the removal that destroys what cannot be made again, and by that one only (A6-R12). Nothing escalates: no step shells out to sudo, and no instruction handed back about lemonfiber’s own files asks the operator to become somebody else — a test sweeps every program run and every instruction returned across three removals and all five platforms (A6-R13). Reachable from the command line (lemonfiber uninstall <removal>), from the web in both halves (/api/uninstall is the listing, the uninstall action is the answer to it), and read from the dashboard — which is deliberately short of the half that removes, recorded as partial in the parity table.
One inventory of every credential, showing none of them A7-R1, A7-R2, A7-R3, A7-R9, A7-R10, A7-R11 credential.rs for the shape, credential/held.rs for the declared set, app/credentials/reading.rs for what is read, render/credentials.rs and /api/credentials for the two surfaces. lemonfiber credentials names every secret the stack holds — the operator’s, the ones the services minted, and the ones lemonfiber minted — with what authenticates with each, where the value lives and where it stands. The inventory cannot show a value because there is nowhere to put one: Held has no value field, so a surface that rendered everything it was given would still render none, which is a property of the shape rather than a rule somebody has to keep. The one place a value is carried is Revealed, built only by a confirmed ask; the first ask prints the warning about scrollback and shoulder-surfing and prints nothing else, which is what makes a credential reach a terminal only through a request that says what it is for. What the storage protects against is printed every time, both halves, and the first sentence of it says the values are stored as text and are not encrypted — a test names four ways of overstating it and fails on each. Stale credentials are surfaced as advisories that say in words that nothing here expires them; nothing in the code path expires anything. The web serves the reading and neither of the two acts, which the parity table records as a gap by choice rather than an exception.
Rotation that validates before it destroys, and reports every consumer A7-R4, A7-R5, A7-R6, A7-R7 app/credentials/rotating.rs, credential/rotation.rs. The ordering is the feature: a replacement is set on the live service and proven there before lemonfiber’s record of the old one is overwritten, so every path out that is not a landed replacement writes nothing at all — Rotation::kept_the_existing is that guarantee written where a test holds it, and four tests drive the four ways it can fail rather than only the way it succeeds. Two credentials can genuinely be replaced from here and the rest cannot, and the row says which. qBittorrent’s web UI password is minted afresh, set through the client that confirms it by signing in again, and recorded only then. A service’s own API key is read from the file it wrote, proven by asking the service to identify itself with it, and only then published — which is also the repair for A7-R7: the inventory compares what each service holds against the copy the stack’s own services read, reports a mismatch as a regenerated key naming both by fingerprint and neither by value, and the rotation is what hands the current one out again. Everything else answers Elsewhere with the place a replacement comes from and the command that takes it, because a value invented here would be one no service has ever heard of — and refusing to rotate destroys nothing, so the guarantee holds there too. Propagation is derived from the declared consumer list rather than written out a second time, so a consumer added to a credential turns up in the next rotation’s report; each is reported as holding the replacement, as waiting on a named command, or as having failed. The consumer that made this necessary is the tunnel’s forwarded-port push: it authenticates to qBittorrent’s web UI on every connect and release, reads the password out of its container’s environment, and is therefore reported as pending lemonfiber restart torrent rather than silently counted as reached.
Credential files owner-only, and a finding when they are not A7-R8, A7-R13 config/store.rs writes them 0600 inside a 0700 directory, set as the file is created rather than tightened afterwards, so a secret is never briefly world-readable; doctor/permissions.rs is the check that notices when something else has widened them. What it guards is read off the same declaration that marks which of the things lemonfiber keeps holds a credential, so a file added there is guarded the day it is added. The stack’s own configuration directory is deliberately left out and the module says why: those files are written by the services inside their containers under a umask lemonfiber does not set, and a finding about them would be permanent with no remedy behind it. Nothing read is never a pass: the check counts what it managed to read before it decides, so a machine where none of the files exists reads skipped rather than green — the shape that would otherwise let a guard report success on the machine it was written for. An open file warns with CONFIG-4, names each file and its mode, and carries the chmod for each as the remedy.
The credential lemonfiber mints because the service offers nothing durable A7-R14, A7-R12 secret.rs mints it, seed/services.rs’s wire_qbittorrent_password sets it and confirms it, app/seed/clients.rs records it where the forwarded-port push reads it, backup.rs marks any capture carrying it sensitive at the moment the plan is made. qBittorrent mints a throwaway web UI password on each start and asks for it to be replaced, so there is nothing stable to read: seeding reads the temporary one out of the container’s log, replaces it with 192 bits from the operating system’s own randomness, and confirms the change by authenticating again — only a confirmed change is recorded, and without randomness the connection fails rather than falling back to something guessable on the client the forwarded port authenticates to. The operator never handles it: they are not asked for it, not shown it, and not asked to copy it anywhere. The same shape covers the media server’s administrator password and the listening server’s first account.
Long-running commands hosted by the machine B10-R1..R16 ports/hosting.rs for the seam, adapters/hosting/launchd.rs and adapters/hosting/systemd.rs for the two managers, app/hosting.rs for what a reading means. Two of this product’s guarantees were made by a command that dies with its terminal, and both are now installable as a service the operating system runs: the guard on the data location and the clock that closes requests nobody ruled on. One mechanism covers both rather than one each (B10-R2) — HOSTABLE is the list, the report is built by walking it, and a test asserts the hosted set is that list, so a third long command is offered by being added to one array. Nothing here reports success from having written a file. A reading asks the manager and carries its answer: a manager that will not say reads installed-unverified (B10-R8), which is the state where somebody believes a guarantee is in force and it is not, and it is spelled in the report exactly as it is spelled in the requirement. A definition naming a program that has since moved reads orphaned (B10-R15) rather than as hosting anything, which is what a self-update leaves behind. Removal takes back everything installing made (B10-R10), and so does a failed install: an install that wrote a definition the manager then refused removes the definition again, so there is no half-installed state to find later. Removing what was never installed says so and succeeds (B10-R11). Nothing needs administrative rights (B10-R5) — a launch agent in the operator’s own login session on macOS, a --user unit in theirs on Linux, and nothing another account on the machine inherits. Nothing is restarted (B10-R14): neither hosted command is a thing to bring back, because the guard returns when the volume goes and the clock stops when the arrangement is withdrawn, and a manager told to restart them would restart them past the reason once every few seconds forever. So the plist carries no KeepAlive and the unit says Restart=no, and both are asserted. Installing starts it and says so (B10-R7), and says where a command with no terminal writes what it would have said in one (B10-R13). Installing is an act of its own (B10-R6): no other command reaches it, and running either long command offers nothing. On a platform with no manager lemonfiber configures, the reading says so and instructs, and reports nothing as installed (B10-R4) — which is Manager::Unsupported, resolved from HOST_OS at the edge and handed in, so all three answers are reachable from one machine. A systemd user session ends at logout unless the account lingers, which is a setting on the account rather than on this service; the report says that beside the manager rather than turning it on for somebody. The honesty this exists for is B10-R16: the reminder that names an expiry period used to say flatly that nothing runs the clock. It says that while nothing does, and names what is running it once something is. The command line is whole; the web is whole (/api/hosting, hosting-install, hosting-remove); the screen asks the reading and is short of the two words that change it, which the parity table records as a gap with what would close it named.

Signed, multi-platform release automation and the install paths a non-contributor follows. The cargo-dist pipeline runs on every tag and, from v0.3.0 onwards, each release carries attested archives for both macOS targets and both Linux targets, their checksums, and a shell installer. Windows and the tap are what the install paths still want.

Every row below used to cite no requirement at all, which made this the one section of this file nothing could check — the release gate reads identifiers, and there were none to read. The spec defines them: L1 is release engineering, L1-R1..R10, and 1.0.0 locks all ten. They are cited now, on the rows that owe them.

Deliverable Spec Status Landing / notes
CI hardening — DCO, CODEOWNERS, SonarCloud gate, OpenSSF In force since M0.5, and the half this row led with is true: every one of the ten repositories builds behind the citation-gated workflow, and every one has a DCO check and a SonarCloud gate. The two named in the title that are not everywhere are the reason this is no longer ticked. CODEOWNERS is absent from lemonfiber-web, sdk-php, sdk-ts and website-docs.lemonfiber.app — no file at any path — and scorecard.yml is absent from exactly the same four, so OpenSSF hardening reports on six repositories and not on ten. Four repositories accepting a change with no owner named is not a gap a reader of this row could have suspected from the word “hardening”.
cargo-dist release workflow — mac (arm64/x86_64), Linux (gnu/musl), Windows L1-R1, L1-R2, L1-R9 release.yml builds the four targets configured in Cargo.toml, attests them (github-attestations = true) and leaves a draft a maintainer publishes (L1-R2, L1-R9). It has not done that since 0.1.0, which this row used to claim. The workflow fired on v0.1.0 and v0.2.0 and finished in fifteen and eighteen seconds; both releases carry zero assets. The first release that actually built anything is v0.3.0, and every release from it carries thirteen. So the pipeline has been exercised end to end since v0.3.0 — a true claim about a shorter history, and worth the precision, because “every tag since the first” would have somebody looking for artefacts on two releases that have none. Windows is the missing target and is deferred (L1-R1) — the native binary does not compile yet, so targets omits x86_64-pc-windows-msvc.
homebrew-tap — auto-published by CI L1-R3 The homebrew-tap repo exists and holds a formula, and the formula is a declared placeholder: version 0.0.0, no url and no checksum, with a header saying so. publish-jobs = [] here and no publishing workflow on either side, so nothing has ever written it. The tap’s own README says brew install lemonfiber/tap/lemonfiber resolves to nothing installable, and that the publish job turns on at 1.0.0 once the tap has a token.
Shell + PowerShell one-line installers L1-R4, L1-R5 The curl-piped shell installer is built and attached to every release from v0.3.0 (lemonfiber-installer.sh, from installers = ["shell"]) — not to v0.1.0 or v0.2.0, which carry no assets at all. The PowerShell irm installer installs the Windows binary and is deferred with that target. L1-R4 and L1-R5 also ask that an installer verify a signature before placing an artifact and refuse one that does not verify; that half is unexamined here, and this row makes no claim about it.
Real Windows + Linux testing — beyond “it compiles” L1-R6 Stated more precisely than “not started”, because one part of it is not. build.yml runs cargo test --workspace on ubuntu-latest, so the suite genuinely runs on Linux rather than merely compiling there. What does not exist is any of the rest: no hand-written job runs on a macOS or Windows runner — the only macOS runners in this repository come from the release build matrix, which builds and does not test — and nothing on any platform installs a release and runs it, which is what L1-R6 asks for.
Docs site — generated from the spec L1-R7 website-docs.lemonfiber.app renders the spec read-only at docs.lemonfiber.app/spec/, mirroring it through a pinned submodule — the site’s spec directory is a symlink into vendor/spec, and a workflow guards the pins. It is an Astro/Starlight site, not mdBook; the spec repo has no book.toml and no SUMMARY.md, and its just docs builds the redirect site that stands where the book stood. This row named an mdBook build that no longer exists. Partial rather than done because L1-R7 asks for publication with each release, and the site deploys on its own changes rather than on a lemonfiber tag.
Epoch completeness — every v1 feature Accepted and done before 1.0.0 OPS-R54, L1-R10 The gate that refuses to ship 1.0.0 while any tracks: v1 feature is unfinished. Confirmed absent rather than assumed: check_stageable.py is the staging gate and enforces the manifest rules only — it holds no epoch or tracks check — and closes_epoch, which 1.0.0’s manifest does declare, is read by nothing but two generators. L1-R10 carries the same refusal beside the requirement that a bare 1.0.0 opens the dashboard, which is why it is cited here too. OPS-R54 is locked by no version manifest, so no release gate would ever ask for it either.

Exit criteria: a non-contributor installs and runs lemonfiber on macOS, Linux and Windows following only the README (L1-R8). Unverified, and one third of it impossible today: there is no Windows artifact to install.

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