Contract: versioning & compatibility
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
Three things version independently and must stay compatible: the lemonfiber
binary, the stack it operates, and the manifest format between them.
Satisfies: E1-R1, E2-R9, E2-R11, F1-R9, F1-R12
The three versions
| Version | Owns | Scheme |
|---|---|---|
lemonfiber binary |
The tool | Semver |
| Stack content | The service set and forms | The lemonfiber-media-stack commit the binary’s assets/media-stack submodule pins |
schema_version |
The manifest format | Monotonic integer |
They are separate because they change for different reasons. An image pin changes
the stack, which reaches a binary when its assets/media-stack submodule is moved
to the commit carrying it. A manifest field changes the format, which before the
first release candidate changes in place at
schema_version = 1. A TUI fix changes only the binary. The stack’s own
stack_version is read and reported, and stays at 0.1.0 through every pin change.
Why schema_version is an integer, not semver
Semver’s minor/patch distinction implies backwards-compatible change — and for a parsed format that distinction is unreliable in practice. A field addition is compatible only if every consumer ignores unknown fields, and a field becoming optional is compatible only in one direction.
A monotonic integer states the only thing that matters: can this parser read
this file? Yes or no. Each lemonfiber release declares the set it supports.
The compatibility check
flowchart TD
load[Load manifest] --> sv{schema_version<br/>supported?}
sv -->|No| refuse[Refuse — name both versions]
sv -->|Yes| mcv{stack requires a<br/>newer lemonfiber?}
mcv -->|Yes| refuse2[Refuse — name required version]
mcv -->|No| validate[Validate contents]
validate -->|violations| report[Report all, with locations]
validate -->|clean| ok[Proceed]
Three distinct refusals, three distinct messages. Collapsing them into “invalid manifest” would leave the operator guessing which of three unrelated problems they have.
The core makes the first and the last. It parses min_cli_version and compares it
with nothing, so the middle refusal, which ARCH-R4 requires, is never made.
Where skew is caught
The embedded stack (ADR-0005) means the common case never reaches a user:
| Path | Caught |
|---|---|
| Embedded stack | Compile time — build.rs validates the submodule’s schema_version |
--stack-dir fork |
Load time, refused with both versions named |
| Manifest edited in place | Load time |
Turning version skew into a build failure is the strongest available mitigation for the main cost of the four-repo split. An incompatible pairing cannot ship.
Changing the schema
| Change | schema_version |
|---|---|
| Add an optional field | Unchanged |
| Add a field whose absence the reader must be able to trust | Increment |
| Add a required field | Increment |
| Remove or rename a field | Increment |
| Change a field’s type or meaning | Increment |
| Add a permitted enum value | Increment — older parsers reject unknown values |
| Add a service, profile or form | Unchanged — that’s stack_version |
Adding an enum value increments deliberately: a stricter parser rejecting an
unknown criticality is correct behaviour, and pretending otherwise produces a
failure that looks like corruption.
The absence row is subtler and was learned rather than designed. An optional field is usually compatible because a reader that ignores it behaves as it always did. It is not compatible when the reader draws a conclusion from the field being absent — an older manifest, which simply predates the field, is then indistinguishable from a newer one declaring the answer is “none”.
Before the first release candidate
None of the above binds yet.
schema_version protects readers, and until a release candidate ships there are
no readers to protect: the stack is embedded, both repositories move together,
and the only manifest any binary has ever seen is the one it was built against.
Incrementing now would buy nothing and cost a predecessor parser to maintain,
plus a compatibility window to test, for a generation nobody ran.
So until the first release candidate, the schema changes in place. Fields are
added, removed and given new meanings at schema_version = 1, and the pairing
stays honest because the build refuses a stack it cannot read
(ADR-0005) rather
than because a number moved.
From the first release candidate the table above binds, and the first breaking change after it increments.
This is written down because the alternative is doing it by accident. A required
field was already added at schema_version = 1 before this section existed —
correct in substance, undocumented in intent, and indistinguishable from an
oversight by anyone reading later.
Supported window
lemonfiber supports the current schema_version and one predecessor. That gives
one release cycle of overlap for anyone maintaining a fork, without carrying
parser variants indefinitely.
Dropping support is a breaking change for the binary and moves its major version.
This window is stack.toml’s, and is not the plugin manifest’s
The trade above is priced for a fork: one maintainer, actively tracking the project, who will bump when told. One cycle of overlap is generous for them and cheap for us.
It is the wrong trade for plugin.toml, whose authors are
many and mostly not watching. A plugin that was finished is not abandoned, and a
two-generation window would delete the long tail of the catalogue at every
second schema bump — experienced by the operator not as “this plugin is old” but
as “my media server stopped”. A plugin manifest generation is therefore supported
until it is deprecated by announcement and then removed, never merely by being
overtaken; the reasoning is
there rather than restated here.
Binary and configuration
lemonfiber holds no irreversibly-migrating state, so downgrade is supported
(E2-R10) — unlike the *arr databases, where it isn’t
(E1).
Configuration written by a newer binary is refused rather than modified
(E2-R11). Silently downgrading a config file is how a downgrade-to-test becomes
an unrecoverable state.
The machine-readable output contract
F1-R12 makes machine-readable output a stable interface, so it versions too:
{ "api_version": 1, "kind": "status", "data": { … } }
Every payload carries api_version. Additive changes leave it alone; removing or
retyping a field increments it. Scripts can assert on it rather than
pattern-matching output shapes.
A narrower break lands without moving it only as a declaration: the field, how it moved
and why, written into the core’s list of declared breaks under the version it was
accepted at. The check holding the contract to its committed surface accepts exactly that
change and refuses every other, another change to the same field included. Moving
api_version leaves a declaration accepting nothing.
| Field | Under | Moved | Why |
|---|---|---|---|
PluginPair.approval |
1 | May be absent, from 0.18.0 | A pair to a service in the stack carries nothing off the machine and asks for no approval, so it carries none. A pair to an external host carries one. |
What a release declares about the stack it carries
schema_version protects a reader from a manifest it cannot parse, and the embedded
stack means the pairing inside one release is proven at build time. Neither covers
the question asked before an update: an operator running one version is offered
another, and nothing they can see says whether the release on offer carries a newer
manifest generation than the copy they are running reads.
Without that number, the statement E2-R9 asks for can only be made generically. “A release carries its own pinned stack” is true of every release, which is less than a requirement worded an update requiring a newer stack schema is asking for. A conditional statement needs the condition, and the condition has to travel with the release.
A release declares it as the name of a published asset: one asset per release,
named stack-schema-<n>, where <n> is the schema_version of the stack that
release carries. The file’s contents are not read and carry no meaning.
The name rather than the contents is the whole of why this is cheap:
| Where it is read | The release list reply the update check already fetches |
| What it costs | Nothing — asset names are in that reply already |
| What it transmits | Nothing new; the request is unchanged |
| What it needs | No second request, no new host, no authenticated call |
The third row is the one that matters for G8-R4, which forbids an update check transmitting any identifier, configuration or usage information. Reading one more field out of a reply that has already arrived transmits nothing, so the privacy stance is untouched rather than traded against — which is what makes this the shape to prefer over a metadata endpoint of our own.
A release carrying no such asset predates this, or belongs to a fork that publishes none. It reads as not stated rather than as a schema. A copy that cannot tell says so, the way a tag it cannot order is reported rather than ranked: being wrong about whether an update brings a newer generation is the failure this exists to prevent, and a guess is how it happens.
What this does not do is gate anything. The declaration is read to say something before an update, and a release whose schema cannot be told is still installable — the binary that arrives carries its own stack and reads it at build-proven parity.
Requirements
| ID | Requirement |
|---|---|
| ARCH-R1 | The binary, the stack content and the manifest format MUST version independently: the binary by its semver version, the stack content by the lemonfiber-media-stack commit the binary’s assets/media-stack submodule pins, and the format by its integer schema_version. |
| ARCH-R2 | schema_version MUST be a monotonic integer, not semver. |
| ARCH-R3 | An unsupported schema_version MUST be refused with both the found and supported versions named. |
| ARCH-R4 | A stack declaring a min_cli_version above the running binary MUST be refused, naming the required version. |
| ARCH-R5 | Unsupported schema, insufficient binary version, and content violations MUST produce distinct messages. |
| ARCH-R6 | The embedded stack’s schema_version MUST be validated at build time. |
| ARCH-R7 | lemonfiber MUST support the current schema_version and exactly one predecessor. |
| ARCH-R8 | Adding a permitted enum value MUST increment schema_version. |
| ARCH-R9 | Machine-readable output MUST carry an api_version. |
| ARCH-R10 | Configuration written by a newer binary MUST be refused, never modified. |
| ARCH-R43 | Before the first release candidate schema_version MUST NOT increment; the schema changes in place, and the build-time refusal is what keeps a pairing honest. |
| ARCH-R98 | Every release MUST declare the manifest schema_version its stack carries, as the name of a published release asset, so that a copy reading the release list can tell whether an update brings a newer generation without a second request. A release that declares none MUST read as not stated rather than as any particular schema. |
Related
- stack-manifest.md — the format being versioned
- ADR-0004 Four-repo split — the skew risk this mitigates
- ADR-0005 Embedded stack assets
- E1 · E2