← The specification

Writing a plugin

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

Status: Accepted · Audience: Operator · Area: F — Extensibility


Purpose

Say what somebody actually does, from an empty directory to something an operator can install — and say what they are given to do it with.

Everything else in this area describes a plugin from lemonfiber’s side: what it is, what it may reach, what installing one does, what stays readable afterwards. None of it says what the author’s afternoon looks like, and an extensibility design that nobody can comfortably write against is an extensibility design in name only.

The instinct here is to reach for an SDK, because that is what the API got (ADR-0013). It is the wrong instrument, and the reason is worth stating rather than leaving as an omission somebody later reads as an oversight.

Behaviour

The schema is published, and it is generated

The manifest format is published as a schema with every release, generated from the types lemonfiber deserialises rather than written beside them (ARCH-R92).

That single artefact is the authoring experience. A published JSON Schema gives an author completion, inline validation and hover documentation in any editor that speaks it, in any language, with nothing installed and nothing to keep in step. It is a better instrument than a library for the same job, and there is exactly one of it.

There is no plugin SDK, and that is a decision rather than a gap

ADR-0013 gives an SDK a precise job: the web API is a runtime protocol with semantics no schema expresses — a heartbeat, so a silent stream is distinguishable from a dead one; values held across a reconnect being stale by definition; a version mismatch refusing rather than rendering a partial view. An SDK exists so that every consumer does not reimplement those three bugs.

A plugin has none of them. It is not a client. It opens no connection, holds no session, handles no stream, and authenticates to nothing. It is an artefact the core reads. There is no transport to own, so there is nothing for a library to hold.

Building one anyway would do active harm, for the reason ADR-0014 already gives: a schema that is written is a claim about the server; a schema that is generated is a description of it. A plugin SDK would be a second, hand-maintained description of the manifest, and the day it disagreed with the parser the symptom would be a plugin that validates in its author’s editor and is refused on an operator’s machine.

Three artefacts, and every one of them generated

The schema is not the only thing an author needs published. Two more decide whether what they write does anything: the capability vocabulary they may claim from (F4-R18), and the extension points they may contribute at (F4-R20). Both are generated the same way and attached to the same releases, and the binary answers from all three with no network and no running stack.

Their absence is what the first two published plugins ran into. Every capability either of them claimed was namespaced, because there was no core name to claim, and a namespaced capability is inert — so both installed a container and wired nothing. That was not a limit of the format or a mistake by either author. It was one missing file, twice. With the vocabulary published, plugin-komga claims the core media.serve; plugin-uptime-kuma still claims only names of its own.

The binary is the toolchain

Fetching, validating, rehearsing, proving, installing and removing are already required to be plain subcommands with meaningful exit statuses (F3-R13, F6-R13). That is the authoring tool, and the author already has it: it is the same binary the operator runs.

Every one of them works against a local path. Nothing about writing a plugin requires the catalogue, a network, or anybody’s permission.

A plugin is testable without the service it configures

This is the part that decides whether authoring is pleasant or miserable, and it is the one piece that has to be built rather than reused.

A plugin’s proofs — and, once F8 lands, its recipes — talk to a service. Requiring the real thing would mean “to write a plugin for Plex, own a Plex server”, and it would mean the catalogue’s CI cannot run declared proofs at all, which F5-R13 requires it to. So an author records a service’s responses once, and proves against the recording.

The recordings are data and are reviewed like the manifest: a fixture nobody can read is a place for something to hide.

A recording may be one an assertion fails on, said so and why

Some checks exist to find a state nobody can record them passing on. Plex’s check that the server has an owner passes only on a claimed server, and claiming one needs a plex.tv account, which this plugin’s CI does not hold. What can be recorded is the state the check exists to find. A proof can be in the same position: what it asserts holds on the service an operator installs, and not on the one recording anybody can take.

So a declared proof and a contributed check may each say, against a recording it names, that it fails there, which of its constraints fails, and why. Plex’s check names /MediaContainer/claimed, the place that says whether the server has an owner. That is a declaration of an expected verdict (expected), and it changes what proving against recordings reports and nothing else:

  • It is reported as what it is. An assertion that fails where its declaration says it does, on the constraint the declaration names and no other, is reported as failing as declared, naming that constraint, what the answer held there, and the reason. That is neither a pass, which would say the assertion holds, nor a failure, which would say the plugin is wrong. A reader sees it every time proving runs.
  • It excuses one failure, not any. A declaration names what fails. An assertion that fails on that recording on a constraint the declaration does not name has failed for a reason nobody declared, and is reported as failed, naming the declared constraint and every constraint that failed.
  • It goes stale loudly. An assertion whose declared constraint holds on the recording is reported as failed, naming the declaration. The recording has changed or the assertion has, and the declaration is no longer true. It is removed rather than left to excuse something else.
  • It excuses one recording. The live service, and every other recording, hold the assertion to its own expectation. A declaration never changes a verdict about the service an operator runs.
  • It excuses only a verdict that was reached. An assertion that could not be run on the recording its declaration names is unproven, as any other is (ARCH-R122). A declaration never stands in for a run.

The only verdict a declaration may name is failing. Passing is what an assertion declares already, and unproven is what a declaration must never excuse. A probe cannot carry one: a claim is what other services are wired on, and a claim whose probe fails is not wired to (F4-R6), however the failure is annotated.

A template that already passes

An author starts from a repository containing one manifest, its fixtures, and a workflow that runs the same commands the catalogue’s CI runs. Beginning from something that validates and proves is the difference between an afternoon and a weekend, and it means the first thing an author sees is the bar they will be held to.

Its manifest opens with a #:schema line naming the manifest schema of the lemonfiber its targets.toml names — the one published with that release, or the one committed at that commit — so an editor that reads TOML schemas checks the file as it is typed, against what that build reads rather than against whatever main holds on the day. CI refuses the line and targets.toml naming different ones, so moving the one moves the other.

Before a release exists, a plugin targets a commit

A plugin is proved against the lemonfiber its targets.toml names. Usually that is a release, lemonfiber = "X.Y.Z", and the reader fetches its binary. A plugin written against what the core has on main and no release carries yet names a commit instead, in full: commit = "<40 hex>". No release or pre-release is cut to give a plugin something to build against; a release goes out when its goals are met and for no other reason.

With a commit, the reader builds lemonfiber at that commit the way the release train’s gate builds its candidate: the dist profile, the toolchain the commit pins, its committed lockfile and every submodule it embeds. It keeps the build under the commit, so it is built once, and asks it every question it would ask a release. The report it writes names the commit where it would name a release.

A commit main does not hold is refused by name before anything is built. A branch of somebody’s fork, or a commit since rewritten away, is not something any release will carry, and a plugin proved against it has proved nothing a release can keep.

Shipping one needs nothing but a git repository

A plugin is published by pushing it somewhere an operator can fetch from. The catalogue is a convenience and a review mechanism, not a dependency (F5-R10) — an author who never opens a pull request against it has still shipped, and an operator can install what they wrote (F5-R4).

States

Per plugin, while it is being written:

State Meaning
drafting A manifest exists; the schema is being satisfied
schema-valid It conforms and may be considered
rehearsed What installing it would do has been stated against a real stack; nothing written
proven-against-fixtures Its declared proofs pass against recorded responses, or fail on one where a declaration says they do
proven-against-service Its proofs pass against the real service
published Fetchable from a source an operator can name

Edge cases

Situation Behaviour
An author’s editor knows nothing about the schema The same commands validate from the terminal with the same messages. The schema is a convenience, not the mechanism.
A manifest validates in the editor and is refused by lemonfiber A defect in the generated schema, not in the manifest. The generator and the parser come from one set of types precisely so this cannot be an ordinary occurrence (ARCH-R93).
An author has no instance of the service to test against Record fixtures, or take somebody else’s. Proving against a recording is a first-class outcome, not a degraded one.
A check’s passing state cannot be recorded without an account nobody holds Record the state it exists to find and declare that the check fails there, on the constraint that tells the two states apart, with the reason. It is reported as failing as declared, never as passed (F10-R12, F10-R13).
A recording an assertion is declared to fail on starts passing The run fails, naming the declaration as stale. The declaration is removed in the change that made the recording pass (F10-R14).
An assertion fails on its declared recording, but on a constraint the declaration does not name Failed, naming the declared constraint and what actually failed. A declaration excuses the failure it describes and no other (F10-R14).
A declaration names a constraint the assertion does not make Refused by name, with the constraints it does make (F10-R12).
An assertion with a declared failing recording is run against the live service Held to its own expectation. A claimed-server check fails on an unclaimed server an operator runs, as it exists to (F10-R15).
The recording a declaration names is absent, unreadable, or of another request Unproven, naming the recording. The declaration does not turn it into failing as declared (F10-R16).
Fixtures drift from what the service now does The proof passes against the recording and fails against the service. Both results are reported as what they are; a fixture is evidence about a moment.
An author wants a helper library in their own language Nothing stops them writing one. What is not published is an official second description of the format.
A plugin needs what the core has on main and no release carries targets.toml names that commit of main, in full, and the reader builds and asks it (F10-R18, F10-R19). No release is cut for it.
targets.toml names a commit main does not hold, a commit not in full, both a release and a commit, or neither Refused by name, and nothing is built or asked (F10-R18).
A plugin is written against a newer lemonfiber than the operator has Refused by naming the capability the manifest asked for, never by naming a version (F3-R21).
An author asks what adapters or capabilities they may name Answerable non-interactively from the binary; the sets are published rather than discovered (F4-R18, F4-R20, F8-R13).
A validation failure is unclear It names the location in the manifest and what was expected, and reports every violation in one pass (F3-R22). Authoring is where G4’s error model earns its keep.

Acceptance criteria

ID Requirement
F10-R1 The published manifest schema MUST be fetchable with every release, and MUST be sufficient to validate a manifest without running lemonfiber.
F10-R2 Authoring MUST NOT require a language-specific library, and the generated schema MUST be the only description of the manifest format published to authors; a second, hand-maintained description MUST NOT be published beside it.
F10-R3 Validating, rehearsing and proving MUST each work against a local path, with no catalogue and no network involved.
F10-R4 A plugin’s declared proofs MUST be runnable against recorded responses, so that neither authoring nor the catalogue’s CI requires a live instance of the service.
F10-R5 Recorded responses MUST be readable data, reviewable in the same way the manifest is.
F10-R6 A proof run against recordings MUST be reported as such, and MUST NOT be presented as a proof against the service.
F10-R7 A template plugin MUST exist that validates and proves unmodified, and its CI MUST run the same commands the catalogue’s CI runs.
F10-R8 The sets an author may name — adapters, core capabilities, and what a manifest may require of lemonfiber — MUST each be readable non-interactively.
F10-R9 Publishing a plugin MUST require no infrastructure beyond a git repository.
F10-R10 An authoring failure MUST name its location in the manifest and what was expected, and MUST report every violation in one pass.
F10-R11 Superseded by F10-R12–F10-R16: an assertion failing where its declaration says it does is reported as failing as declared, not as passed. The number is not reused.
F10-R12 A declared proof and a contributed check MAY each declare, against a recorded response it names, that it fails there on one constraint of its expectation, which the declaration MUST name. Every such declaration MUST carry a reason, and a declaration without one, naming a recording that does not exist, naming a constraint its assertion does not make, or naming any verdict other than failing MUST be refused by name.
F10-R13 Proving against recordings MUST run an assertion against every recording a declaration names and judge every constraint of its expectation there. One whose declared constraint is judged false there while every other constraint holds MUST be reported as failing as declared, apart from passed and from failed, naming the declared constraint, what the answer held there, and the declaration’s reason. It MUST NOT be reported or counted as passed, and MUST NOT by itself fail the proving run.
F10-R14 An assertion whose failure on a declared recording is not the one declared MUST be reported as failed and MUST fail the proving run: where the declared constraint holds, naming the declaration as stale; where a constraint the declaration does not name is judged false, naming the declared constraint and every constraint that failed.
F10-R15 A declaration MUST apply only to the recording it names. Against any other recording and against the live service, the assertion MUST be held to its own expectation, and the declaration MUST NOT change its verdict.
F10-R16 An assertion MUST be reported as failing as declared only where it was run against the named recording and its expectation was judged false there. One that could not be run there MUST be reported unproven, and a declaration MUST NOT stand in for a run.
F10-R17 The template plugin’s manifest MUST name, in the form an editor reads, the manifest schema of the lemonfiber its targets.toml names — the schema published with that release, or the schema committed at that commit — and its CI MUST refuse the two naming different ones.
F10-R18 A plugin’s targets.toml MUST name exactly one lemonfiber to be proved against: a release, as lemonfiber = "X.Y.Z", or a commit of lemonfiber’s main, in full, as commit. The reader MUST refuse by name a file naming neither or both, a commit not in full, and a commit main does not hold, and MUST build and ask nothing when it refuses.
F10-R19 Where targets.toml names a commit, the reader MUST build lemonfiber at that commit the way the release train’s gate builds its candidate — the dist profile, the toolchain the commit pins, its committed lockfile and every submodule it embeds — MUST keep that build under the commit and ask the kept build thereafter, and MUST ask it every question it asks a release. The report it writes MUST name the commit where it would name a release.