ADR-0028: A supported major is data the stack declares, proved by its own recordings
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted Date: 2026-09-25 Decided: 2026-09-25, by the maintainer, Wessel Verheij: a line is one major, and each line’s pin follows the newest release of that major.
Context
The stack runs one version of the media server, and the core is written against
that one. lemonfiber-media-stack pins jellyfin/jellyfin at tag 10.10.3, in
stack.toml and in compose/media.yml, with no digest. E1-R1 and the
stack manifest require a
digest, and the pin does not carry one yet. The
identity contract
states the posture plainly: the writer and this document move together when a
pin advances. One version at a time, and the code follows the pin.
Upstream has moved twice since that pin. The release list shows:
| Major | Releases | Standing upstream |
|---|---|---|
| 10 | 10.10.3 (2024-11-19) to 10.10.7 (2025-04-05), then 10.11.0 (2025-10-20) to 10.11.11 (2026-06-06) | Ended; 10.11.11 is the last 10.x |
| 12 | 12.0 (2026-09-08), 12.1 (2026-09-15) | Current stable |
“v12” is Jellyfin 12. At 12.0 upstream dropped the leading 10.. The release
after 10.11 is 12.0: the first number is now the major and the second the minor.
GET /System/Info/Public on the 12.1 image answers "Version": "12.1.0".
There is no Jellyfin 11. Before 12, upstream shipped its large changes as 10.x
releases, and 10.10 to 10.11 in particular carries a one-way database migration.
What differs, measured. On 2026-09-25 the three images were run side by side:
10.10.3 (sha256:17c3a8d9…bc221), 10.11.11 (sha256:aefb67e6…35db) and 12.1
(sha256:78d3ea12…dd7e), as pulled. The calls the core makes, and the ones the
household work depends on, were each issued against all three:
| Behaviour | 10.10.3 | 10.11.11 | 12.1 |
|---|---|---|---|
Sign-in with the core’s X-Emby-Authorization header |
200 | 200 | 400 |
A token in X-Emby-Token, X-MediaBrowser-Token or ?api_key= |
accepted | accepted | 401 |
A token in Authorization: MediaBrowser … Token="…", or ?ApiKey= |
accepted | accepted | accepted |
HasPassword on an account with no password |
false |
false |
true |
Rating value of TV-PG in /Localization/ParentalRatings |
13 | 10 | 10 |
A second rating dimension (MaxParentalSubRating, RatingScore.subScore) |
absent | present | present |
| Fields a new account’s policy returns, nulls omitted | 42 | the same 42 | the same 42 |
| A partial policy body | 400 | 400 | 400 |
IsDisabled=true: sign-in, then an existing token |
403, 401 | 403, 401 | 403, 401 |
| An administrator authorises a Quick Connect code for another user, and the session issued is that user’s | 200, yes | 200, yes | 200, yes |
GET /Items with no token |
401 | 401 | 401 |
GET /Videos/{id}/stream?static=true with no token |
200, the file | 200, the file | 200, the file |
GET /Items/{id}/Download and the HLS playlist with no token |
401 | 401 | 401 |
CorsHosts default, and an empty list |
["*"], reads as * |
same | same |
| An API key’s reach | full administration | same | same |
Three rows decide the shape of this ADR. The core as it stands cannot sign in to
12.1 at all. On 12.x an account’s password state is no longer reported: upstream
marks HasPassword “no longer provided” and HasConfiguredPassword “always
true” in UserDto.cs at v12.1. That is the field D6’s unclaimed state is read
from today. And 10.11 changed what the integer the core writes to
MaxParentalRating means: the same number admits different ratings on 10.10 and
10.11.
Some moves are one-way, including one inside major 10. Upstream’s 10.11 and 12 migrations start only from 10.10.7 (or, for 12, from 10.11.x). The 10.11.0 announcement says upgrading from any other version “is NOT supported and WILL fail”. From 10.10.5, a 10.10 server refuses to start on a database 10.11 has migrated. The 12.0 notes say rolling back needs a full restore. 10.10.3, the current pin, cannot migrate directly.
The version that runs is not always the one pinned. In native mode
(ADR-0007) the operator installs the media server,
and lemonfiber neither pulls it nor chooses its version (B2-R15).
Two precedents already exist. api.version on a servarr service makes the API
generation “data the manifest carries rather than a guess the client makes”. The
manifest format supports “the current schema_version and exactly one
predecessor” (ARCH-R7).
Decision
A bundled service may declare more than one supported major. Each major is a line in the stack manifest, with its own digest, an API profile the core’s client reads, and recordings from that digest that prove every claim and every call the core makes. A line is split only on a major version, and its pin always follows the newest release of that major. The core detects which line is running and uses that line’s profile. No code compares version numbers. Jellyfin is the first service to have two lines: 10 and 12.
1. A line is declared, and pinned by digest
[[service]]
id = "jellyfin"
image = "jellyfin/jellyfin"
api = { kind = "jellyfin", key_source = "generated" }
default_line = "12"
[[service.line]]
major = 10
tag = "10.11.11"
digest = "sha256:…"
profile = "profiles/jellyfin/10.toml"
standing = "supported"
via = ["10.10.7"]
[[service.line]]
major = 12
tag = "12.1"
digest = "sha256:…"
profile = "profiles/jellyfin/12.toml"
standing = "supported"
via = ["10.10.7"]
Each line carries its own digest and tag (E1-R1). via names the releases an
update must pass through, because upstream supports a migration only from them:
a 10.10.3 server reaches 10.11.11 or 12.1 through 10.10.7. A service declaring one
line has the shape every service has today.
1a. A line’s pin follows its major’s newest release, and a bump proves itself
A major carries no breaking change by definition, and the newest release of a major is the one upstream still fixes. So each line’s pin always moves to the newest release of its major, and never across a major. Moving it is automatic:
- A scheduled workflow in
lemonfiber-media-stackreads the registry’s tags for each line, keeps those whose version is in the line’s major and is not a pre-release, and resolves the newest to the digest of its multi-architecture index. It is a workflow of the stack’s own rather than a Dependabot update: Dependabot does not editstack.toml, and it rebuilds its own branches, which would discard the re-recorded files. - It re-records every recording of that line from the new digest: it runs the
image in CI, drives the same requests, and writes each recording with the new
recorded_from. Each recording’snoteis carried over, and a changed answer reviews as a diff. - It opens one pull request per line moving
tag,digest, the compose image and the recordings together. - CI proves every claim and every operation of that line against the new recordings (section 5). A green bump merges on its own.
- A bump that breaks a claim is refused and reported, never silently held
back. Its pull request stays open and red, naming the claim or operation it
broke and showing the recording that broke it. Until it is resolved the line’s
standing is
held, carrying the release it could not take, in the services envelope and the doctor. The fix is a profile change (data) or a new strategy (code, section 3) in the same pull request, and the bump then merges.
A pin moving changes what an update offers. It updates no operator’s running server: applying it is an E1 update the operator chooses.
2. A profile is what the client reads, and it is data
A profile lives in lemonfiber-media-stack beside the pin and reviews as a diff. It
may declare:
| Field | What it says | Example, line 12 |
|---|---|---|
reports |
The pattern Version in GET /System/Info/Public must match |
^12\. |
auth |
Which scheme, from the set the client implements, carries a token | mediabrowser-authorization |
operations |
For each operation the core performs: method, path and the strategy that performs it | disable = { method = "POST", path = "/Users/{id}/Policy", strategy = "policy-round-trip" } |
claimed |
Which strategy tells a claimed account from an unclaimed one | own-writes |
ratings |
Where a rating’s value is read in /Localization/ParentalRatings, and which policy fields a limit is written to |
RatingScore.score, RatingScore.subScore; MaxParentalRating, MaxParentalSubRating |
bytes |
The recorded answer to an anonymous byte request | open |
Ratings are read from the running server rather than tabulated, because the table is the server’s own: it changed between 10.10 and 10.11, inside one major.
3. What “without code changes” covers, and what it cannot
Covered, as data:
- A new release within a major, which the bump in section 1a takes.
- A new major whose differences are paths, field names, where a value is read, or which existing strategy an operation uses.
- The version pattern, the default line, the
viareleases, and a line’s standing, including its retirement.
Not covered: a behaviour that differs in kind. Where a new line removes the thing an existing strategy relies on, no data can select a strategy that does not exist. The measured cases:
- 12 refuses the legacy headers the core sends. The modern header was accepted by all three releases measured, so it replaces the legacy one for every line. It is a one-time change and needs no variant.
- 12 stops reporting whether an account has a password. D6’s unclaimed
state needs a second
claimedstrategy. The proposal, which is inferred and has not been proved against a recording yet, isown-writes. It is proved against the recordings of both lines before any core work relies on it. The core records when it made or reset an account, taking the time from Jellyfin’s own activity entry for that write. A laterUserPasswordChangedentry for that account is then a claim. A reset and a claim both writeUserPasswordChanged, as measured on 10.10.3 and 12.1, so only the ordering tells them apart.
How such a change is contained. A new behaviour is a named strategy in that service’s client, and a profile selects it:
- A strategy is written once and proved by the recordings of every line that names it.
- The core never compares a version, so a strategy is never an
if version >= 12. - A new strategy is a core release, and the profile that selects it is data.
- A pinned line whose profile names a strategy this binary lacks is refused by
naming the strategy, the way a manifest’s unmet requirement names the
capability (
F3-R21).
4. The running line is detected, never assumed
The core reads GET /System/Info/Public and matches its Version against each
line’s reports. On every line measured, this read needs no credential. The match
has three outcomes:
- The running version matches a line. The core uses that line’s profile.
- It matches a line, at a release older than that line’s pin. This happens
before an operator applies an update, in native mode, or with an image pulled
by hand. The core uses the line’s profile, reports the server as
older, and says that the line’s recordings were taken from a newer release. The doctor offers the update, naming anyviarelease it passes through. - It matches a line other than the one pinned. The core uses the running line’s profile, and the doctor reports the difference.
- It matches no line. Every call to the media server is refused, naming the running version and the lines supported. Nothing is guessed.
A detection is a reading and carries its age like any other.
5. Every line proves every claim, and CI runs the whole matrix
A bundled service binds its capability probes the way a plugin does (ARCH-R109),
once per line:
- The capabilities are
media.serveandidentity.source, and every probe they declare is bound on each line. - Each probe’s recording names that line’s digest as
recorded_from. - Each operation in a line’s profile has a recorded exchange from that digest.
- The recordings are readable data (
F10-R5) and are reported as proofs against recordings (F10-R6).
The recordings live beside the profiles in lemonfiber-media-stack, and the core
embeds them with the stack. The core’s CI runs its client, every claim and every
operation against the recordings of every line the manifest declares. A version does not ship while any line’s
claim or operation is refused. That is F9-R2, applied per line. Moving a line’s
pin means re-recording that line in the same change.
6. The contract says which line is running, and behaviour arrives as capabilities
The services envelope (GET /api/services and the --json it mirrors) carries
five fields per service:
line: the major that is runningrunning_version, as readtaganddigest, as pinnedrunning:at-pin,older,outside(no line matches) orunreadline_standing:supported,leaving, orheld(the newest release of the major was refused, section 1a)
The companion reads these to show them. It does not derive behaviour from them. A
feature the running line cannot perform arrives as absent or unconfigured in
/api/capabilities (ARCH-R78), so no client carries a table of which line does
what.
7. The operator chooses a line, and moving is optional
- Choosing a line. A new installation takes
default_line. An existing one stays on its line, and staying is never nagged about (E1-R14). - Moving to another line is an update across a major (
E1-R10):- It states that the move is irreversible (
E1-R3). - It backs up first (
E1-R4). - It passes through the target line’s
viareleases where the running release predates them.
- It states that the move is irreversible (
- Updating within a line is an ordinary update, and may still migrate.
10.10.x to 10.11.11 is a move within major 10. It is also one-way: 10.11
migrates the database, and a 10.10.5 or later server refuses to start on it
afterwards. So the update says so and backs up first (
E1-R3,E1-R4), and from 10.10.3 it passes through 10.10.7. That is a consequence for the operator, stated at the update, and not a reason for a line of its own. - Going back is restoring the backup taken before the move (
E1-R8,E4-R5), across a line or within one. Nothing is downgraded in place across a migration, because upstream refuses to start on a migrated database.
8. The window: two majors, each at its newest release
A service supports an explicit list of majors, never more than two at once: the newest stable major upstream ships, and the major before it. That is N-1 by major. For Jellyfin today the two lines are:
- 10, pinned at 10.11.11, the newest 10.x release.
- 12, pinned at 12.1.
A line is never split below a major. 10.10 and 10.11 are both major 10, so they are one line, and its pin is the newest 10.x. What 10.11 changed inside that major, the migration and the rating scale, is stated to the operator at the update (section 7) and read from the server (section 2), not carried as a second line.
- When a line leaves. A line is marked
leavingfor at least one lemonfiber release before it is removed. The doctor and the services envelope say so. - Security. Tracking the newest release of each major is also how a line takes upstream’s fixes. A major upstream has stopped releasing keeps the advisories its last release carries, and a line on such a major states them. Major 10 is in that position: its last release is 10.11.11.
9. Bytes are open on every line, and the guard does not read the line
On all three releases measured, an anonymous GET /Videos/{id}/stream?static=true returns
the file, while the catalogue, the download route and the HLS playlist refuse. The
source matches:
VideosControllerandAudioControllercarry no[Authorize]on these actions at any tag fromv10.10.3tov12.1.- No fallback policy is registered.
- Upstream issue #13984, “Video streams unauthenticated”, is open.
So the media server enforces discovery and not bytes. The playback grant proposed
in spec#537 rests on the Quick Connect row above, which holds on all three releases,
and on this: a member’s session limits what the member can find, not what bytes
an item id fetches. media.serve’s guarded
probe asks for the catalogue, and it passes on every release even though bytes are
open.
The front-door guard the maintainer has decided for byte requests is therefore needed on every line, and it is never relaxed per line:
bytesis a recorded fact in each profile, proved by that line’s recording of the anonymous request.- A doctor check asserts that the guard refuses that same request. It is proved
by firing on a recording taken with no guard in front (
F10-R11), so it tests the guard rather than the media server. - A future line whose recording shows a refusal changes that line’s fact, and the guard stays in place. Dropping a guard because a version number says it is unnecessary is the version branching this decision forbids.
Alternatives considered
| Option | Why it lost |
|---|---|
| One line at a time; the code follows the pin (today) | Every major becomes a flag day for every operator at once. Because upstream’s upgrades are one-way, an operator can neither hold back nor try the new line and return. That is the migration optionality the decision exists to keep. |
| Branch on the version number in code | The client carries a table of which release does what, which is the second copy of a changelog that ARCH-R78 refuses for the contract. Native mode running an unlisted version then passes silently through whichever branch its number falls into. |
| Probe behaviour at run time instead of declaring it | Finding out which header, field or strategy works by trying it means writes and sign-ins against a live server. A failed sign-in increments the account’s InvalidLoginAttemptCount and disables it at LoginAttemptsBeforeLockout (UserManager.cs, at v10.10.3 and v12.1), and testing whether an account is claimed by signing in as it records a failed attempt against a member who has a password. Nothing discovered at run time can be proved in CI. |
Each major as a separate plugin claiming media.serve |
Two claimants of one capability collide (F4-R8). The client that differs is the core’s own code, and a plugin cannot carry code (F3-R6). |
| A line per upstream minor (10.10 and 10.11 apart) | Splitting below a major multiplies recording sets for releases upstream has stopped fixing, and holds operators on superseded patches. A minor’s migration is a consequence stated at the update, which E1 already requires. |
| Hold a line’s pin until someone moves it | The pin falls behind upstream’s fixes while nothing says so. Following the newest release automatically, with a refused bump reported as held, keeps the lag visible and short. |
| Support every major upstream has shipped | The recording matrix grows without bound, and majors upstream has stopped patching accumulate advisories nobody fixes. |
| Follow upstream’s newest line only | It removes the option this decision exists to give. With one-way upgrades, the newest line without a supported predecessor is the flag day above. |
Consequences
Positive
- An operator can stay on 10 while 12 is proved, and move when they choose, with a backup and a stated way back.
- A new release within a major reaches the pin without anyone moving it, proved before it merges. A new major that only reshuffles known strategies is a data change reviewed as a diff.
- Every supported line is proved in CI against recordings from its own digest, and a line the core cannot serve is refused by name rather than half-served.
- Native mode gets a checked answer to which version is this rather than an assumption.
Negative
- Two recording sets, re-recorded on every release of either major, and a workflow in the stack’s repository that runs each image in CI to do it.
- Before 12 works at all, the core needs a one-time change: the modern
Authorizationheader, and anown-writesstrategy for claimed accounts, which has yet to be proved. - The stack manifest gains
[[service.line]]anddefault_line. Before the first release candidate this changes the schema in place (ARCH-R43), and afterwards it increments it. - The pin has to move from 10.10.3 to 10.11.11 and gain digests on both lines
before either line meets
E1-R1. For an existing installation that update migrates the database, through 10.10.7. - Major 10 has ended upstream, so its line carries whatever advisories 10.11.11 carries. Keeping it keeps them, stated rather than hidden.
- A server on an older release of a line runs on recordings taken from a newer
one, and is reported as
olderuntil it is updated.
Neutral
- A service with one line behaves exactly as today.
- The rule that no code compares a version number applies to every service, and Jellyfin is only the first with two lines.
Revisit if
- Upstream makes the byte endpoints require a token on a line in the window.
- Upstream breaks a claim within a major, so that following the newest release stops being safe to automate.
- Upstream reports an account’s password state again, making
own-writesunnecessary. - A second bundled service needs two lines and the profile fields above do not fit it.
- Keeping two lines’ recordings current costs more than the migration option is worth to operators.
Related
- E1 Stack updates: pins, majors and irreversibility
- E4 Rollback: where a migration means restore
- F9 Capabilities of the bundled services
and F10 Authoring:
claims, recordings and
fires_on - D6 Household identity and D8 Parental controls: what the per-line differences touch
- Stack manifest · Capability vocabulary · Versioning · Web API
- ADR-0007: native mode, where lemonfiber does not choose the version
- ADR-0023: a pin is a digest