ADR-0049: A moved pin is recorded again by the core
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted Date: 2026-10-13 Decided: 2026-10-13, by the maintainer, Wessel Verheij:
lemonfiber plugin recordrecords every recording a manifest names, requests as well as runs: a fresh container of the pinned digest under the service’s posture, its first run done through the adapter lemonfiber already has for it, an operator’s probe asked with the key that first run left lemonfiber holding, and every recording written withrecorded_fromset to the image that ran;- a first-party plugin’s upstream image pins move every week, to the newest release of
each pin’s major, one rolling pull request per image, through a reusable workflow of
spec’s that records them again with that command in the same change; - the media stack’s own
pinsworkflow stops in the change that turns those callers on.
Amends ADR-0048: §3, the record mode, which records request proofs, claim probes, contributed checks and conformance cases as well as runs.
Context
A recording names the digest it was taken from, and one naming any digest but the
manifest’s pin is refused (ARCH-R121, ARCH-R217). Moving a pin therefore means
recording again in the same change.
The media stack moves every upstream pin each Monday: scripts/pins.py takes the newest
tag in the pinned tag’s spelling and major, and scripts/record.py records each claim
again from a fresh container (ADR-0028
§1a). The recorder holds a first-run recipe of its own for each service it records:
about 460 lines of Python that read a key from a file, finish a setup wizard or sign in
as an image’s documented administrator.
ADR-0045 moves each part into a plugin repository and freezes the media stack at the source switch. Measured against those repositories on 2026-10-13:
- Each of the seventeen part repositories that pins an upstream’s image and has no
adapter carries one or two recordings naming that image’s digest, and
plugin-jellyfincarriesfixtures/and the conformance recordings of the two contracts its adapter speaks, which only its own.github/record.pywrites. - Nothing in a plugin repository moves an upstream pin. Once the media stack is frozen, no upstream image would move again.
lemonfiber plugin recordrecords run proofs only (ARCH-R285), and no build ships it yet.- A first-party plugin carries no Python of its own: the reader is
first-party-plugin’s. - The plugin check already goes red where an upstream publishes a tag again under a new
digest, because the reader’s
imagestep holds the tag to the pinned digest.
The core already knows each part’s first run. An assembled part’s in-process adapter,
named by api, reads its key through api.key_source; an installed part’s adapter
performs its first run over its contract (F14-R26, F14-R28).
Decision
plugin recordrecords every recording a manifest names. Each run proof’s, asARCH-R285already holds; each request proof’s, each claim probe’s and each contributed check’s; and, for a service an adapter of the pluginfronts, every case of each contract that adapter speaks. It takes no option: the manifest is the list, as it is for run proofs.- One fresh container per service. It is started from the pinned digest under the
service’s posture, user and memory limit, with a throwaway copy of its starting files
and a throwaway data directory, the entry’s constants and
TZ,PUIDandPGID. Its network is one of its own that no other container joins, with the adapter that fronts it as the one exception, and itslistensport is published on loopback alone. It is waited on until itshealthpasses within its owntimeout_s, and every one of its recordings is asked of that one container. - The first run is the core’s own. For an assembled part it is the first run the
in-process adapter
apinames performs. For an installed part it is the adapter started from its own pinned digest beside the service, given the key andupstream.jsonlemonfiber writes at install, and the first run install performs over its contract. A service with neither has no first run. - An operator’s probe is asked with the key that first run left lemonfiber holding.
A probe whose vocabulary entry says
credential = "operator"presents it as the adapter presents it; every other request presents nothing beyond what the manifest writes (ARCH-R123). A recording never holds the key. - What is written is what came back, scrubbed.
recorded_fromis the image that ran, and for a conformance case the image of the service the adapter fronts. The request is the one the manifest declares; the response is its status, itscontent-type, and its body as parsed JSON or as its first 1 KiB, cut at a character boundary, as a run’s output is recorded. Every credential the run made or read, in every encoding an answer could echo it in, and the container’s id, hostname and addresses are replaced with<redacted>; a recording still holding one is not written. Thenotealready at that path is kept. - A service is recorded whole or not at all. Where its container, its health, its first run or any of its requests fails, none of its recordings is written, the ones already there stay as they were, and the run goes on to the next service. The run fails naming the service, the step and why, with the last 50 lines of the container’s log scrubbed and stripped of control characters. Every container and network it started is removed through the engine API, as ADR-0048 §5 starts a run proof’s.
- A first-party plugin’s upstream pins move every week.
spec’supstream-pinsworkflow, on the plugin repository’s schedule, moves each image not underghcr.io/lemonfiber/to the newest tag in its pinned tag’s spelling and major whose index publisheslinux/amd64andlinux/arm64, and takes a digest that moved under an unchanged tag. Each image moves on its own rolling branch, recorded again byplugin recordat the lemonfiber itstargets.tomlnames, withproofs.jsonwritten from the new recordings. The image runs and lemonfiber is built in a job that holds no secret. The job holding the App’s token runs none of it: it works the move out again from main’s manifest and the registry, and commits only plain files, never read through a link: main’s manifest with exactly that move,proofs.json, and recordings the manifest names taken from exactly that pin. A bump that leaves no recording naming the old image is armed to merge and the required checks judge it; one that does is opened disarmed, naming each (OPS-R87). - One mover holds each upstream’s pin. The callers are turned on in the same change
that stops the media stack’s
pins, at the source switch (ADR-0045 §11), so the stack and the plugin repositories never move the same pin apart.
Consequences
- No part’s first run is written twice: the recording uses the code the stack runs.
- The media stack’s per-service recipes are not carried anywhere; the adapters replace them.
plugin-jellyfin’s.github/record.pyis replaced byplugin recordonce the core records conformance cases.- An adapterless part’s recordings change only through the core, so a part the core cannot first-run is a part whose pin cannot move clean.
- The callers wait on a core build that records requests; until then a bump opens disarmed and says the recordings were not taken.
F2-R14’s release date stays the bundle’s: the catalogue’s reviewed pin pull request moves it beside the plugin’s revision.
Alternatives considered
| Option | Why it lost |
|---|---|
Port the media stack’s recorder into spec |
Eighteen first-run recipes in a repository that holds no part’s code, written a second time beside the adapters that already perform each first run. |
| The recorder in plugin-template’s reader | The same recipes, copied into every plugin’s CI. |
| Move the pin and list the stale recordings | Every bump stays red until someone records by hand, and the bot’s next rebuild of the branch discards what they pushed. |
Rewrite recorded_from without running anything |
A recording would name an image it never came out of, which is the drift ARCH-R121 exists to refuse. |
| Dependabot’s docker manager | It does not edit plugin.toml, records nothing, and rebuilds its own branches. |
| A container per recording | Every first run repeated for each probe of the same service, for nothing a second container would show. |
| No network route out | Some upstreams finish their first run only once they reach the internet, and Docker publishes no port from a network without one. |
Revisit if
- A part’s first run cannot be performed by its adapter without an operator.
- An upstream’s answers differ from one fresh container to the next in a way scrubbing cannot name.