Contract: plugin.toml — [[proof]]
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
Part of the plugin.toml contract: the checks a plugin declares,
which must hold before lemonfiber installs it. The requirements this page describes are
listed with the rest of the contract’s, under
Requirements.
[[proof]] — what must hold before it is installed
[[proof]]
id = "plex.serves"
title = "Plex answers on its declared health path"
request = { method = "GET", path = "/identity" }
expect = { status = 200, json_has_keys = ["MediaContainer"] }
fixture = "fixtures/identity.json"
why = "The path the health probe asks for is one this image serves."
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | ✔ | Unique within the plugin. What a verdict is reported against. |
title |
string | ✔ | What it establishes, in one line |
request |
table | ✔ unless run |
method and path. The same shape a health probe takes. |
run |
table | ✔ unless request |
Arguments to the service’s own image, run with no network — see A proof that runs the image’s own program. Exactly one of request and run. |
expect |
table | ✔ | What the answer must be. A status alone is not sufficient — see below. |
fixture |
string | A recorded response to run against where no instance exists (F10-R4) |
|
service |
string | ✔ where the plugin declares more than one service | Which of this plugin’s services is asked. Default: the only one. |
why |
string | ✔ | Why this is worth asserting. A proof nobody can justify is one nobody will maintain. |
expected |
array of tables | Recordings this proof fails on, each with the constraint it fails and the reason — see What an assertion is declared to fail on |
F3-R1 names a plugin’s proofs among what its manifest declares, F3-R3 runs
them in the existing verification engine, and [[proof]] is where they are
declared — so F3-R4, a plugin whose proofs do not pass is not installed, has
something to evaluate.
expect must constrain the body. A status is a claim about the network
path, not about the service: Docker publishes a port by putting a proxy in front
of it, and that proxy accepts a connection before knowing whether anything
inside is listening. A manifest whose every proof asserts only a status MUST be
refused, naming the proofs, because it has declared nothing a replaced container
would fail (ARCH-R105). An exit status alone is a status: a run proof whose
expect checks only exit counts as one that asserts only a status. The core holds a [[claim.probe]] to its probe’s body
constraints and holds a [[proof]] to nothing of the kind: a proof whose expect
is a status alone is installed and run as written. 0.18.0 locks ARCH-R105.
What an expectation may say
One vocabulary, read in three places — a [[proof]], a [[claim.probe]] and a
contributed check — because all three ask a service a question and judge the
answer, and three vocabularies for one job would be three things to keep in step.
| Key | Type | What it asserts |
|---|---|---|
status |
integer | The response status. Not sufficient alone, except for a refusal — see above. |
json |
table | Places the body must carry, each with the exact value it must hold. A value is a boolean, a whole number or a string; nothing nested, because a shape deeper than that is asking about a document rather than about a claim — and where the thing worth asserting is deeper in the answer, the key reaches it rather than the value growing to match. |
json_has_keys |
array | Places the body must carry, whatever they hold |
json_types |
table | Places the body must carry, each with the kind of value it must be: bool, int, str, list or dict |
json_at_least |
table | Places the body must carry, each with a number it must not be below. At least one series, rather than a catalogue exists. |
json_array_min |
integer | The body read as a JSON array, with at least this many entries. A catalogue is very often a list rather than an object, and none of the key-wise constraints can say anything about one. |
json_is_absent |
boolean | The body did not parse as JSON at all. How a proof says this answered with an application shell, not an object — which is what a client-routed service answers for every path it does not implement, and the reason a status proves nothing against one. |
content_type |
string | A substring of the content type the answer was served as |
body_starts_with |
string | What the body must begin with, where it is not JSON |
The set is closed. A key outside it is refused by name rather than ignored, for
ARCH-R91’s reason pointed at an expectation: an assertion nothing evaluates is
a proof that silently checks less than it says, which is worse than one that
fails.
json_is_absent is the one worth reading twice, because its name invites the
other reading — these keys are absent — and the two are not close. It says
nothing about keys. It says the answer was not JSON.
Where an expectation looks
The four key-wise constraints above take a place rather than a name. The other five are about the answer as a whole and take none.
A key that does not begin with / is the name of a top-level member, which is
what every key written before this generation is and is why none of them changed
meaning. A key that does begin with / is a
JSON Pointer, with one extension this
contract defines.
| Key | Reaches |
|---|---|
content |
the top-level member content |
/MediaContainer/machineIdentifier |
machineIdentifier inside MediaContainer |
/MediaContainer/Setting/[id=PublishServerOnPlexOnlineKey]/value |
the entry of the Setting list whose id holds that word, then its value |
/MediaContainer/Directory/[type=movie]/Location/[path=~1data~1media~1movies]/path |
the film library’s location under the stack’s data root |
/a~1b |
the top-level member literally called a/b (RFC 6901’s escapes: ~1 is /, ~0 is ~) |
Standard, and ours. Everything but the third row is RFC 6901 unchanged. The
third is the extension, and it is one step and one comparison: a reference token
written [field=value] means the entry of this list whose field holds
value, exactly one entry must, and there are no operators, no wildcards, no
nesting and no indices.
An index would be the trap the selector exists to avoid. Plex answers a hundred
and fifty-one settings at /:/prefs and the order of them is not a promise
anybody made, so the ninety-first is the wrong answer one release later, in a
way that keeps passing.
A selector’s value is escaped like any other reference token, which the fourth
row is there to show and which the common case needs: a filesystem path is full
of slashes, and a slash in a token is written ~1. Unescaped,
[path=/data/media/movies] is four steps rather than one, and is refused by
name rather than resolved somewhere nobody meant.
Its cost is stated rather than hidden. [ and ] belong to the selector, so a
reference token carrying either is refused rather than read as a member name — a
member actually called [a=b] is unreachable through a pointer. That is the
price of the extension; it also closes the mistake anybody would actually make,
which is writing …/Setting[id=X]/value and being told nothing while it looks
for a member with brackets in its name.
Why a name was not enough. A flat name says everything there is to say about
a flat answer, and the two plugins published before this one both have flat
answers. A service that nests its payload — Plex puts every response one level
down under MediaContainer — could only be asserted about at the envelope, so
json_has_keys = ["MediaContainer"] was the strongest claim available and it
says that the service replied. A probe that passes by observing that something
replied is worse than one that fails, because a port proxy replies.
A key naming no place is refused when the manifest is read, naming the key
and what is wrong with it. It is not evaluated as a missing member: an assertion
nothing can evaluate is one that silently checks less than it says, which is
ARCH-R91 pointed at an expectation.
[[recipe.step]].capture.from reads an answer with these same keys, so one
dialect says where a value is, whether a proof is asserting about it or a recipe
is taking it.
Three verdicts, never two (F3-R5, F4-R7): passed, failed, and could not be
run. The third is reported as unproven and is never counted as the first.
Against a recording a proof or a check declares it fails on, a fourth is
reported in place of the second: failing as declared.
A proof that runs the image’s own program
A part with no network surface has no answer to ask for. Quality sync is a program its image runs on a schedule: it publishes no port, listens on nothing and declares no health probe beyond the container running. Its proof runs the image’s own program once, in a container of its own, and judges what it exits with and what it writes first:
[[proof]]
id = "recyclarr.runs"
title = "The pinned quality sync starts as the service runs it"
run = { args = ["--version"], timeout_s = 30 }
expect = { exit = 0, stdout_starts_with = "v8.7.3 (" }
fixture = "recordings/recyclarr/runs-version.json"
why = "It has no port: the live question is whether the pinned program starts under its posture and its user."
| Field | Type | Required | Notes |
|---|---|---|---|
run.args |
array of strings | ✔ | Passed to the image’s own entrypoint as its arguments, one string each. One to 64 of them, each at most 4096 bytes and none holding a NUL. |
run.timeout_s |
integer | How long the run may take, from 1 to 120 seconds. Default 30. |
| Key | Type | What it asserts |
|---|---|---|
exit |
integer | The exit status. Not sufficient alone, as a response status is not. |
stdout_starts_with |
string | What the standard output must begin with |
A [[claim.probe]] takes run only where the vocabulary declares its probe a run,
which it does for quality.sync alone, the one capability whose fillers listen on
nothing (capability vocabulary,
ADR-0050); it takes request for every other probe. A contributed check is a
question asked of a running service, so it takes request and nothing else. A run’s
expect takes these two keys and no other, and a request’s takes none of them; each is
refused by name where the other’s is written (ARCH-R283).
The manifest names no program. args go to the entrypoint the image declares,
the program the service itself runs, so the proof asks the pinned program a question
rather than starting something of the manifest’s choosing. lemonfiber starts no shell
and passes each string as one argument, so nothing in args is read as a command, a
variable or a pattern.
It runs apart from the service, and with less. lemonfiber starts a new container
from the image at the digest the manifest pins, never a command inside the running
service, and removes it when the run ends (ARCH-R284):
| What the run has | What it is |
|---|---|
| Its network | None. The container has no interface but loopback. |
| Its user | The one the service’s user_mode writes |
| Its posture and memory | The service’s own: its posture’s hardening and its mem_limit_mib |
| Its configuration directory | Where the service declares a config_path, a throwaway copy of the plugin’s starting files for it (config/<id>/) there, or an empty directory where it ships none. Never the service’s own directory. |
| Its environment | The entry’s constants, and TZ, PUID and PGID as lemonfiber writes them. No setting, no secret and no URL. |
| Any other mount, device or shape | None |
| Its time | timeout_s. A run still going then is stopped and fails, naming the limit. |
| What is read of it | The first 64 KiB of standard output. Standard error is never judged. |
What the run writes is the program’s, not lemonfiber’s, so every report shows it with
its control characters removed, and none of it reaches a terminal as anything but text.
An engine lemonfiber cannot reach, and an image it does not hold at the pinned digest,
leave the proof unproven, naming which (F3-R5).
Where it runs. Wherever a proof does: against its recording in the plugin’s CI and
in the release gate, live in the plugin’s CI with the image under its posture, at
install and at update, and in lemonfiber plugin prove (ARCH-R286). The doctor runs it
too, on its own cadence rather than the dashboard’s: only when the doctor is run, never
on the gather that feeds the dashboard or the event stream, and only where something it
depends on has changed since the verdict it keeps. That verdict is kept across restarts
against the image’s digest, the proof’s arguments, the posture and user it ran under,
the digest of the starting files and the lemonfiber release that ran it, and reported
with when it was reached. The doctor pulls no image for it. A run is the one check that
may hold a doctor run past C1-R12’s thirty seconds, once, after its pin moves
(ARCH-R287).
What an assertion is declared to fail on
A [[proof]] and a doctor.check row may carry expected: the recordings it
fails on, which of its constraints fails there, and why. It is for an assertion
whose passing state nobody can record, such as a check that a server has an
owner where claiming one needs an account the plugin’s CI does not hold
(F10-R12).
[[contribution]]
at = "doctor.check"
id = "plex:claimed"
request = { method = "GET", path = "/identity", accept = "application/json" }
expect = { status = 200, json = { "/MediaContainer/claimed" = true } }
fixture = "fixtures/identity-anonymous.json"
expected = [
{ fixture = "fixtures/identity-anonymous.json", verdict = "fails", constraint = "json", place = "/MediaContainer/claimed", reason = "Recorded from a server nobody has claimed; claiming one needs a plex.tv account." },
]
# … title, category, timeout_s and why as for any check
| Field | Type | Required | Notes |
|---|---|---|---|
fixture |
string | ✔ | The recording the assertion fails on. It may be the assertion’s own fixture. |
verdict |
string | ✔ | fails, and nothing else. Passing is what expect already declares, and unproven is never excused. |
constraint |
string | ✔ | The key of this assertion’s expect that fails on the recording: one of the vocabulary’s keys, and one expect carries. |
place |
string | ✔ where constraint is key-wise |
The place within that constraint that fails, written exactly as expect writes it. Absent for a constraint about the answer as a whole. |
reason |
string | ✔ | Why this recording is one the assertion fails on. Reported with the verdict every time. |
An entry names one constraint, and it has to be one the assertion makes. An
entry naming a key expect does not carry, or a place that key does not
constrain, is refused by name, with the constraints the assertion does make
(ARCH-R130).
What each entry changes is the report about that recording and nothing else.
Every constraint of expect is judged, so the report can say which failed:
| The assertion, against the recording an entry names | Reported as |
|---|---|
| Ran; the named constraint was judged false and every other constraint held | Failing as declared, naming the declared constraint, what the answer held there, and the reason (F10-R13) |
| Ran; the named constraint held | Failed: the declaration is stale, naming it (F10-R14) |
| Ran; a constraint the entry does not name was judged false | Failed, naming the declared constraint and every constraint that failed, whether or not the declared one did (F10-R14) |
| Could not be run | Unproven, naming the recording (ARCH-R122, F10-R16) |
Against any other recording, and against the live service, the assertion is held
to expect exactly as if expected were absent (F10-R15). A [[claim.probe]]
has no expected: a claim is what other services are wired on, and one whose
probe fails is not wired to (F4-R6).
What a recording is
A fixture names a file beside the manifest holding one response somebody
recorded from the image this manifest pins. It is what lets a claim be shown
where no instance exists — an author’s laptop, the catalogue’s CI, a reviewer’s
checkout — and it is ordinary reviewable data rather than a cassette a tool
wrote and only that tool reads (F10-R4, F10-R5).
{
"recorded_from": "ghcr.io/example/thing@sha256:6c2a967…",
"note": "An unauthenticated read of the catalogue. A refusal is the pass: a library server on the household network that answered this would be publishing somebody's collection to every device on it.",
"request": { "method": "GET", "path": "/api/v1/series" },
"response": {
"status": 401,
"headers": { "content-type": "application/json" },
"json": { "status": 401, "error": "Unauthorized" }
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
recorded_from |
string | ✔ | The image this answer came out of, as image@sha256:… |
note |
string | ✔ | Why this is the answer worth recording — the half a diff cannot show |
request.method |
string | ✔ | What was asked |
request.path |
string | ✔ | Where it was asked |
request.accept |
string | What representation it asked for, where it asked for one | |
response.status |
integer | ✔ | |
response.headers |
table | The headers an expectation may constrain; content-type is the one in use |
|
response.json |
any | The body as it parsed, or null where it did not parse as JSON |
|
response.body_starts_with |
string | The beginning of a body that is not JSON |
It names the image by digest, and that digest is the manifest’s own pin. A recording taken from some other build is a claim about software nobody is installing, and the drift is silent: it passes, and the service it describes is not the service that will run. Moving the pin means re-recording in the same change, and a recording naming a different image is refused rather than trusted.
What was asked for is part of which call a recording is of. A service that
negotiates answers two different things at one path, so a recording that did not
say which it asked for would be evidence for whichever question somebody later
pointed at it. A recording taken plainly is not evidence for a request that
names an accept, and one that named an accept is not evidence for a request
that does not.
Every recording is taken by lemonfiber plugin record <path>, from the manifest’s
pin, so moving the pin and recording again are one command (ARCH-R285). What it takes
is the manifest’s list, and no option names a recording. A recording under
conformance/ that no case asks is removed.
A pull request proves its own adapter with two options. --check takes every recording
again and writes nothing: it fails naming each recording that differs from what it took
and each one no case answers. An identifier or a moment the service makes afresh on each
run, a UUID, 32 hexadecimal digits or an RFC 3339 date and time, is held to its shape
alone, as it is inside a string that holds a JSON list or object. --adapter <image> starts the adapter from an image the engine already holds, the
one the plugin’s CI built from its own Dockerfile, in place of its pin, and pulls
nothing. A pull request that changes what its adapter answers writes its recordings
this way, from the adapter it built, and checks them the same way. The pull request
that then moves the adapter’s pin to the published image takes every recording again
from that pin and holds it to what was written, so no recording ships that its pin does
not reproduce.
| What the manifest names | How it is recorded |
|---|---|
| A request proof, a claim probe, a contributed check | Asked of one fresh container of the service at its pin, after its first run (ARCH-R295) |
| A conformance case of a contract the plugin’s adapter speaks | Asked of that adapter at its own pin, fronting such a container (ARCH-R296) |
| A run proof | Run, bounded as a proof’s run is (ARCH-R284) |
A request is recorded by asking it. Each service is started once, from its pinned
digest, under its posture, user and memory limit, with throwaway copies of its starting
files and data, on a network of its own, its listens port published on loopback alone.
Once its health passes, its first run is the core’s own: the in-process adapter api
names performs it for a part the bundle assembles, and the plugin’s adapter performs it
over its contract, as install does, for a service that adapter fronts. A probe whose
vocabulary entry says credential = "operator" is asked with the key that first run left
lemonfiber holding. A service an adapter fronts has no such probe, because the manifest
refuses one there (ARCH-R302). Anything else presents nothing the manifest does not
write. Each
recording keeps the request as declared, the status, the content-type, the body as
parsed JSON or its first 1 KiB, and the note already there.
What a recording holds is scrubbed. Every credential the run made or read, in each
encoding an answer could echo it in, each container’s id, hostname and addresses, and
the recording machine’s name, kernel version and operating system as the engine reports
them are written as <redacted> wherever they appear, and a recording still holding one
is not written. A number an answer gives about the machine, such as its count of
processors, is kept as it was answered. A service
is recorded whole or not at all: where any step of it fails, its recordings stay as they
were and the run fails naming the service and the step (ARCH-R297).
A run is recorded by running it, and its recording holds the image it ran, the
arguments, the exit status and the first 1 KiB of standard output, keeping the note an
author wrote.
{
"recorded_from": "ghcr.io/recyclarr/recyclarr@sha256:9c6c74d52cfc6f740a5a3624aa83867e171bb70b90453ddb7a4048b8c80445f9",
"note": "The pinned program, started as the operator's user with no network, says which release it is.",
"run": { "args": ["--version"] },
"result": { "exit": 0, "stdout_starts_with": "v8.7.3 (Branch.master.Sha.237d4ddea46b12c6d1aca45936ddc410c3a13bd8)" }
}
| Field | Type | Required | Notes |
|---|---|---|---|
recorded_from |
string | ✔ | The image it ran, as image@sha256:…: the manifest’s pin |
note |
string | ✔ | Why this is the run worth recording |
run.args |
array of strings | ✔ | The arguments it ran with |
result.exit |
integer | ✔ | The exit status |
result.stdout_starts_with |
string | ✔ | The first 1 KiB of standard output, cut at a character boundary; empty where it wrote none |
A recording holds request and response, or run and result, never both. Running
it again writes the same result, which is how the plugin’s CI proves it live: a run
whose output differs from one run to the next is not evidence of anything, and a
recording whose result a fresh run does not reproduce is refused (ARCH-R285).
A recording that is absent, unreadable, or records a request or a run the assertion does not ask is unproven — never a pass and never a failure. Nothing about the service has been established either way, and reporting it as a failure would say the service is broken when the recording is. It is the third verdict’s plainest case, and the one an author meets most often.
Related
- plugin-manifest — the rest of the contract, its validation and its requirements
- plugin-manifest-recipe — the ordered calls a plugin may declare