Recipes and named adapters
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted · Audience: Operator · Area: F — Extensibility
Purpose
Let a plugin configure the thing it installed, and the services around it, without letting it become a way to move an operator’s secrets somewhere they did not agree to.
F3 says what a plugin is: data describing a service. That is enough to add a service and not enough to substitute one. The case the whole design exists for — run Plex instead of Jellyfin — needs an account created, a server claimed and a token read back, because the media server is what the request service signs in through. Those are first-run flows, and a manifest that could not express them would describe a container nobody had configured.
So a plugin carries recipes: ordered calls that capture values, feed them into later calls, branch on what came back, and wait when something is not ready yet. Where even that cannot reach, it names an adapter lemonfiber implements, and never supplies one.
This is separated from F3 because it is the part with the risk in it. F3’s manifest describes a container whose reach is fixed by lemonfiber (ADR-0021). A recipe is different in kind: it runs with lemonfiber’s own authority — the credential store, every service in the stack on its loopback port, and the open internet — and it does so on behalf of a manifest a stranger wrote. Shipping it in the same version as the first plugin anything would mean the riskiest mechanism arriving before anybody has operated the simplest one.
Behaviour
A recipe is a sequence, not a program
A recipe is an ordered list of calls. Each may capture values out of a response and name them, substitute values captured earlier into a later call, branch on a status code or a captured value, and wait and retry a bounded number of times when a service is not ready.
There is no arbitrary computation, no loop that does not terminate, no destination worked out while running, and no call assembled out of something that came back. A recipe is powerful enough to create an account, claim a server and read back a token — which is what a first-run flow is — and no more powerful than that.
That bound is not tidiness. It is what makes the next section possible: a sequence this shape has a finite, computable set of things it could do, so the question what would this plugin send, and where can be answered by reading the manifest rather than by running it and watching. Relaxing the bound later would not cost elegance; it would cost the only mechanism that makes recipes safe to offer.
A recipe runs on install, or when it is asked for
Most recipes are first-run flows: the account created, the server claimed, the library pointed at. Those run as part of installing the plugin, after its proofs and the stack’s own checks hold and before it is recorded as installed, and again when it is updated. If one fails, the install goes back like any install that does not hold, and the account says which calls had already landed somewhere it cannot reach back into.
Some are not first-run flows: a re-claim after a token was revoked, a library added after the fact. A recipe says which it is. One that runs on demand runs only when the operator asks for that recipe by name, with a rehearsal, an offer and approvals of its own: what was agreed to at install covers the install, and a demand recipe that carries a value out of the machine is agreed to again, each time it is run. One that fails changes nothing about what is installed, and says what landed. A recipe that says neither runs on install, which is what a recipe is for.
An input is asked for before the yes
A recipe may ask the operator for a value it cannot find for itself: an account name, a
token a service printed once. A surface asking for it only once the recipe is running asks at
the moment the operator has already agreed, which is too late to decline on the strength of
the question. So the plugin reading lists, for each recipe, every input it asks the operator
for, with its name, the sentence it asks and whether the value may be a secret, and a surface
collects them before the yes (F8-R19). A value given for a secret input is held and used and
never shown back, by any reading or any answer.
A credential goes back where it came from
A recipe can bring in a credential lemonfiber already holds, because a first-run flow against a bundled service needs one: signing in to Sonarr to point it at a new download client is done with Sonarr’s own key. It can carry that credential to the service it belongs to and nowhere else — not to another service of the stack, not to a service the plugin itself runs, not out of the machine. A plugin may use a key on the door it opens; it may not take the key away.
What a service answers stays with it
A value a recipe reads out of the answer of a service in the stack belongs to that service, whatever it holds. A token, an id, a path and a sentence all look like text, and nothing reading the text can tell which of them matters somewhere else. A rule holding only what looks like a credential, or what a credential was traded for, leaves every other value free, and a service’s own storage is then a way to carry what it keeps anywhere a pair names. So every capture from a service in the stack is held to that service because of where it came from, not what it holds, and it may go back to that service freely. What a call carrying a credential gets back is held tighter still, as the credential itself is: a session handed over for a key opens what the key opens, so nothing releases it.
Carrying one anywhere else is a release. The pair that carries it says so and says why, in a sentence the operator reads:
[[recipe.pair]]
value = "library-id"
to = "sonarr"
release = "Sonarr files new series into the library Komga just made, so it needs that library's id."
The operator agrees to a release at install consent, as itself, the way they agree to a pair leaving the machine. The consent screen names the value, the service it was read from, where it is going and the plugin’s reason. A release frees a capture and never a credential: a value the credential store holds goes to its own service, released or not.
What a recipe may carry is declared as pairs
Declaring the secrets a plugin holds and the hosts it reaches, as two separate lists,
describes both halves of a problem and refuses neither. A manifest saying “I capture the
media server’s API key” and “I reach plex.tv” has declared everything and still
described sending the first to the second.
So the manifest declares pairs: this captured value, to that destination. Every value carries where it came from — a service in this stack, the credential store, the operator, or an external response — and every destination is either a service in this stack or an external host. A flow with no pair behind it is a validation failure, found before anything runs (ADR-0022).
The verbosity is the point. A plugin capturing three values and reaching two hosts has six possible pairs and will want very few of them, and the ones it does not declare are the ones nobody has to wonder about afterwards.
Anything leaving the machine is agreed to, specifically
A pair that carries a value to an external host is stated at the rehearsal in the terms an
operator can judge — this plugin will send your media server’s API key to plex.tv —
and is approved as itself. Not as a consequence of approving the install: a single yes to
an installation is consent to the installation, and the pairs are the one part of a
manifest an operator can meaningfully weigh.
Everything else a rehearsal says is about a change to their own machine. This is the only part about something leaving it.
A recipe names, and never addresses
An in-stack destination is named by its service id, and lemonfiber resolves it to the address it already knows. An external destination is named by a DNS name. A manifest carrying an IP literal, a network range or a bare host port is refused.
Without that rule a declared host is a declared host wherever it points, and the machine this runs on sits beside a router’s administration page, a NAS and whatever else the household owns — none of it reachable from outside, all of it reachable from here. Names close the manifest; they do not close DNS, so an external name that answers with a loopback, private or link-local address is refused at the call. The manifest said this destination was outside; an answer from inside contradicts it, and the contradiction is the refusal rather than a thing to be reconciled.
Where data will not reach, a plugin names an adapter
Some flows cannot be honestly expressed as calls and captures. For those a plugin names one of a fixed set of adapters lemonfiber implements and ships — the Servarr-shaped registration flow, and others as they earn their place. The plugin supplies the parameters; lemonfiber supplies the behaviour.
This keeps the awkward cases in code that is reviewed, tested and covered like the rest of the product, while still letting a plugin reach them. The set is published rather than discovered, and naming one that does not exist is a validation failure listing what is available.
On the companion
Anything leaving the machine is agreed to, specifically is the sentence a phone is most likely to break, because a phone is where one large button covering everything is the natural design. The surface that most wants a single Install is the surface that must not have one (N20).
So N20-R2 is written as a prohibition on combining two agreements rather
than as a requirement to ask twice. The failure is not a missing prompt — it is
a present one that covers more than the operator read it as covering, and a
second prompt bolted onto the first would not fix that. N20-R1 carries what
the first prompt has to say: which value, and to which host, in terms somebody
can weigh.
A recipe names, and never addresses becomes N20-R3 and N20-R4. The app
shows the name the manifest gave; a resolved address in its place answers a
question nobody asked and hides the one they did, because where is this going
is a name rather than a number. And where a name answered from inside the
network, the refusal carries its reason — rendered as a network error with a
retry, it becomes a button that keeps trying to reach a household’s router.
A recipe is a sequence, not a program becomes N20-R5: the rehearsal lists
every step in the order given, and is not summarised into an outcome.
Configures Plex has removed the only thing an operator can actually judge.
Where a plugin names an adapter, N20-R6 requires the app to say which, and
that it is one lemonfiber implements rather than anything the plugin supplied.
States
| State | Meaning |
|---|---|
flows-declared |
Every flow the recipe could produce has a declared pair behind it |
undeclared-flow |
A value could reach a destination no pair permits; refused at validation |
awaiting-approval |
A pair carrying a value out of the machine, or releasing one a service answered with, has been stated and not yet agreed to |
approved |
The operator agreed to each outward and each released pair, named |
address-literal |
The manifest names an address rather than a name; refused |
resolved-inward |
A destination declared external answered with a private, loopback or link-local address; refused at the call |
adapter-unknown |
The manifest names an adapter this lemonfiber does not implement |
Edge cases
| Situation | Behaviour |
|---|---|
| A recipe could send a captured secret somewhere no pair allows | Refuse at validation. The flow is computable from the manifest, so this is found before a call is made rather than after two have landed. |
| A pair leaving the machine, or a released pair, is declared and the operator declines it | The install does not proceed. A declined disclosure is not something to route around by installing anyway and failing later. |
| A manifest declares a pair it never uses | Allowed. Declaring more than is used is conservative, and the account says what was declared. |
A manifest names 192.168.1.1 |
Refuse. A recipe names services and hosts; it does not address machines. |
| An external name resolves into the home network | Refuse at the call, naming the name and what it answered. Checked per call, because the answer can change between them. |
| A recipe branches on a value that was never captured | Refuse at validation; the reference is checkable without running anything. |
| A recipe’s call never succeeds | Bound the retries, then fail naming the call and what it last answered. |
| The manifest names an adapter that does not exist | Refuse, naming the adapter and listing what is available. |
| A plugin needs a destination it cannot know until it runs | Not expressible, and not worked around. The honest answer is a declaration form that is still statically bounded, decided once for everybody. |
| An operator has switched off a plugin’s reach | Its recipes report that nothing was asked, rather than failing as though the destination were down (G8-R16). |
| A recipe carries a credential lemonfiber holds for one service to another, or to one of the plugin’s own | Refuse at validation, naming the credential, whose it is and where it was going. A credential goes back to its own service and nowhere else. |
| A recipe presents a credential to its service, captures the token it answers with, and carries the token somewhere else | Refuse at validation unless the pair carrying it releases it. The token is the service’s because the service answered with it, as every capture from a service is. |
| A recipe carries a value read from one service of the stack to another with no release on the pair | Refuse at validation, naming the value, the service it was read from and the destination. |
| A pair carries a release where it frees nothing: a credential-store value, an operator’s input, an external answer, or a capture going back to its own service | Refuse at validation, naming the pair. A release the operator would weigh for nothing teaches them to stop weighing releases. |
| A call would carry a value somewhere reading the manifest allowed nothing to go, or outside without the approval of its pair | Refuse at the call, before anything is sent, naming the value, the destination and the step, and end the recipe. Reading the manifest is the first check and the call is the second, so a gap in the first is not a leak. |
| A demand recipe carries a pair the operator approved at install | It is asked for again. An approval belongs to the act that carried it. |
| An install recipe fails after a call to an external host landed | The install goes back, and the account names that call as one that cannot be undone from here. |
Acceptance criteria
| ID | Requirement |
|---|---|
| F8-R1 | A recipe MUST be an ordered sequence of calls supporting capture, substitution of earlier captures, branching on status or captured value, and bounded wait-and-retry. |
| F8-R2 | A recipe MUST NOT support unbounded computation, a destination computed while running, or a call assembled out of a response. |
| F8-R3 | Every value a recipe captures MUST carry its origin — a service in this stack, the credential store, the operator, or the response of an external host. |
| F8-R4 | Every destination MUST be classified as a service in this stack or as an external host, and there MUST be no third classification. |
| F8-R5 | A manifest MUST declare each permitted flow as a pair naming a captured value and a destination, and a flow with no declared pair MUST fail validation. |
| F8-R6 | The flow check MUST be performed statically, before any of the recipe’s calls is made. |
| F8-R7 | A pair carrying a value to an external host MUST be approved by the operator at rehearsal, named as that value and that destination, and MUST NOT be approved implicitly by approving the installation. |
| F8-R8 | A recipe MUST name an in-stack destination by its service id and an external destination by a DNS name, and a manifest containing an address literal, a network range or a bare host port MUST be refused. |
| F8-R9 | A destination declared external that resolves to any address not out on the internet, as the plugin manifest contract classes addresses, MUST be refused at the call; the resolution MUST be checked on each call rather than once, every address it answers with MUST be checked, the call MUST connect only to the addresses checked, and no redirect MUST be followed. |
| F8-R10 | A recipe MUST NOT reach any host, service or value the manifest has not declared. |
| F8-R11 | A plugin MAY name one of a published, fixed set of lemonfiber-implemented adapters for flows recipes cannot express, and MUST NOT supply an adapter of its own. |
| F8-R12 | Naming an adapter this lemonfiber does not implement MUST be refused, naming the adapter and the set that is available. |
| F8-R13 | The set of adapters MUST be published rather than discovered. |
| F8-R14 | A recipe MUST declare whether it runs on install or on demand, and a recipe declaring neither MUST run on install; an install recipe MUST run during an install and an update, after the plugin’s proofs and the stack’s checks hold and before the install is recorded, and a failure MUST put the install back and name each call that had already reached somewhere. |
| F8-R15 | A demand recipe MUST run only when asked for by name, with its own rehearsal, offer and approvals, and an approval MUST NOT carry over from another act or another run; a demand recipe that fails MUST change no install record and MUST name each call that had already reached somewhere. |
| F8-R16 | A value a recipe takes from the credential store for a service MUST be held to that service, and so MUST every value a recipe captures from a call that carried or read such a value, which no release frees; every other value a recipe captures from the answer of a service in this stack MUST be held to that service by its origin, whatever it holds; a pair, a call or a guard carrying or reading a held value for any other destination MUST fail validation, except a capture of that other kind carried there by a pair carrying a release, and a release on a pair that frees nothing — a credential-store value, a value captured from a call that carried or read one, an operator’s input, an external host’s answer, or a capture going back to its own service — MUST fail validation. |
| F8-R17 | While a recipe runs, each value it holds MUST carry the destinations it may be sent to: a credential-store value and every value captured from a call that carried or read one its own service alone, any other capture from a service in this stack that service and each destination of a released pair the operator approved in this act, and any other value the destinations its pairs name; a call carrying a value to any other destination, or to a host outside the stack without the approval of its pair, MUST be refused before it is sent, under a code of its own, ending the recipe. |
| F8-R18 | A release MUST be written on its [[recipe.pair]] as release, the sentence saying why the value is carried, and each released pair MUST be shown at install consent, and at every other act that carries it, as the value, the service it was read from, the destination and that sentence, and approved by the operator as itself; it MUST NOT be approved by approving the installation or another pair, and an act with a released pair not approved MUST NOT proceed. |
| F8-R19 | The plugin reading MUST list, for each recipe of a plugin, every input it asks the operator for, with its name, the question it asks and whether it is a secret, so that a surface can collect each before the operator agrees; a value given for a secret input MUST NOT be shown back by any reading or answer. |
Related
- ADR-0022 — why pairs rather than lists, and why the check is static
- F3 — what a plugin is, and the manifest these extend
- F4 — the capability a substitution fills once a recipe has configured it
- F6 — the rehearsal the approval happens in
- G8 — the account of what leaves this machine, and the switch a declared reach gets
- plugin-manifest contract — where the blocks these add are declared