← The specification

Contract: capability-vocabulary.json

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

Status: Accepted

The published set of things a service can do, so that wiring can ask for one rather than name a service, and a plugin can claim one rather than invent a name for it.

Satisfies: F4-R1, F4-R2, F4-R3, F4-R4, F4-R18, F4-R19, F4-R23, F4-R24, F9-R1, F9-R3, F10-R8


Why this file exists

F4 has been able to describe a capability since it was written, and had nothing to point at. The consequence was not theoretical. Every capability the first two published plugins claimed was namespaced with the plugin’s own id — komga:opds, uptime-kuma:status-page — because F4-R4 forbids a plugin inventing a core-looking name, and there was no core name to use instead. A namespaced capability is inert until something asks for it, and nothing asks.

So both plugins installed a container, passed their proofs, and wired nothing. They could be read and they could not be useful, and the reason was one missing artefact rather than anything either author did. Once it existed, plugin-komga claimed the core media.serve in place of komga:comics-serve, with a [[claim]] binding its probes; plugin-uptime-kuma still claims only names of its own, and those stay inert.

This is that artefact. It is a single file, generated from the types lemonfiber reads it with, attached to every release, and readable from the binary with no network and no running stack.

What a capability is, and what it is not

A capability is a named, contracted thing a service can do, carrying probes that demonstrate the contract holds. Three things in this system are called capabilities and only one of them is this:

Thing Where it is read What it means
This provides on a service, and the wiring that asks What a service can do
lemonfiber’s own [requires].capabilities in a manifest, GET /api/capabilities (ARCH-R78) What lemonfiber offers the thing reading it
Kernel grants in stack.toml What the kernel lets a container do

The third was renamed out of collision when F4-R13 was written. The first two still share a word, and the field a name is read in is what tells them apart — which is enough for a reader and not enough for a gate. So the two sets are published as separate artefacts and no name may appear in both (F4-R23). A name that did would be one whose meaning depended on which field it was sitting in, which is exactly the failure F4-R13 refused for the kernel set.

Names

Kind Shape Example Who may declare it
Core area.verb — lowercase, exactly one dot media.serve lemonfiber, here
A plugin’s own <plugin-id>:<name> — exactly one colon komga:kobo-sync the plugin whose id prefixes it

The two shapes are disjoint, so which kind a name is can be decided by reading the name. That is what lets F4-R4 be a rule a gate can hold rather than a convention a reviewer has to remember: a plugin declaring media.serve in provides without binding its probes is refused, and a plugin declaring plex:direct-play is accepted and treated as inert.

A core name is never reused and never quietly removed. Removing one advances the generation and the removed name is carried in removed, so a manifest naming it is told it was removed in generation N rather than no such capability — the same distinction F3-R21 draws between a version that is older and a thing that has gone.

The artefact

{
  "vocabulary": "service-capabilities",
  "vocabulary_version": 1,
  "capabilities": [
    {
      "name": "media.serve",
      "summary": "Serves the filed library to a player, with its own catalogue of what it holds.",
      "contract": "A service claiming this holds a catalogue of what is in the library …",
      "declared_by": ["jellyfin", "audiobookshelf", "calibre-web-automated", "navidrome"],
      "probes": [
        {
          "id": "guarded",
          "title": "An anonymous caller is refused the catalogue",
          "asks": "a read of the catalogue of what it holds, presenting nothing",
          "why": "A refusal is the one answer no port proxy can produce …",
          "credential": "none",
          "requires": { "status": [401, 403], "body": [] }
        },
        {
          "id": "catalogue",
          "title": "It answers with the catalogue of what it holds",
          "asks": "the same read, with the credential the operator holds",
          "why": "…",
          "credential": "operator",
          "requires": {
            "status": [200],
            "body": ["json", "json_has_keys", "json_types", "json_at_least", "json_array_min"]
          }
        }
      ]
    }
  ],
  "removed": []
}
Field Notes
vocabulary Which of the three sets above this is. Present so a file read out of context cannot be mistaken for another one.
vocabulary_version Monotonic integer. Advanced by a removal or by a contract narrowing; adding a capability does not move it.
capabilities[].name The core name. Unique, and never reused.
capabilities[].summary One line, in the terms F2-R1 asks of a bundled service
capabilities[].contract What a service claiming it undertakes to do. The prose a plugin author is held to.
capabilities[].declared_by Every bundled service declaring it. Generated from the stack manifest, not written here — see below.
capabilities[].probes What must be demonstrated. At least one.
removed Names a published generation carried and this one does not, each with the generation it went in.

declared_by is generated, and it is what enforces F9-R3

F9-R3 refuses a capability into the vocabulary that no bundled service declares, because an untested contract is worse than an absent one — a plugin author will trust it. That rule needs somebody to hold it, and a hand-written list of claimants would be a second statement of a fact the stack manifest already makes.

So declared_by is read out of the pinned stack manifest at generation time, and a capability nothing declares fails generation. The rule is enforced by the artefact refusing to be built rather than by a reviewer noticing.

It follows that moving the stack pin can move this file, which is the intended behaviour: a bundled service that stops declaring a capability changes what lemonfiber publishes, and a change nobody meant shows up as a diff in a generated artefact rather than as a plugin that stopped wiring six months later.

A capability is something the stack asks for

That sentence is the whole admission test, and every bundled part passes it. The four below are configured by lemonfiber rather than queried by another service, and each still fills the capability F14-R8 names for it (ADR-0041 §6), so a substitute stands in for it as for any other filler.

Service Capability What filling it means
Recyclarr quality.sync Writing the quality profiles lemonfiber chooses into each curator’s own configuration
Unpackerr archive.extract Extracting what lands in each curator’s download folder, and handing it back to that curator
Homepage dashboard.show Showing the panel list the wiring derives
Caddy proxy.front Serving every household name in the routing table lemonfiber applies

Homepage shows why probeable is not the test: it answers /api/widgets to anybody, and that is not why it provides a capability. It provides dashboard.show because lemonfiber asks a dashboard to show the panel list, and substituting the dashboard is then a change of which service fills it.

A capability earns its place by something needing to ask for it, and the core is one such asker. Quality sync, archive extraction, the stream guard and decline are queried by no service of the bundle: the core asks each of them over its contract, which is what asking means for a part lemonfiber configures (ADR-0051). The core asks dashboard.show too, applying the panel list: over the contract where the filler speaks it, and where the filler is the assembled Homepage, which speaks none, through an in-process adapter that writes Homepage’s own files (ADR-0052). No [[ask]] row stands in for the core.

owner.claim and indexer.pull are not here. Each is a facet the core asks of every service that speaks it, never a capability a filler is chosen for, so a service speaks it and does not provide it (ADR-0053, ARCH-R331).

Probes

F4-R3 requires every core capability to carry probes that demonstrate its contract. The difficulty is that a capability is one contract and its claimants are many services, each answering at a path of its own: Jellyfin’s catalogue is not Komga’s and neither is Audiobookshelf’s.

So the two halves are split where they belong. The vocabulary declares what must be shown. The claimant declares where to ask.

Declared here Declared by the claimant
What question the probe asks, in prose The method, path and representation that asks it on this service
Which statuses are an acceptable answer Which of them this service gives
Which kinds of body constraint the answer must carry, any one of which will do The constraint itself
Whether a credential is needed to ask The recorded response it was answered with

A claim that does not bind every probe its capability declares is refused, naming the probe (ARCH-R109). A binding whose expectation is weaker than the probe requires — a status outside the set, or no constraint where one is required — is refused, naming the probe and what it requires. Neither is a parse failure.

A probe asks for one representation, and can present nothing

A claimant’s request may name an accept (plugin-manifest), because a service that answers XML unless asked for JSON cannot otherwise satisfy a probe whose body constraints are all JSON ones. That is the whole of what a probe may put on the wire beyond a method and a path.

It is one field rather than a header map for the sake of the row below. A probe asked with none presents nothing, and that has been true because a manifest had nowhere to write anything; a header map would have made it a convention instead, unenforceable because a service may call its credential header whatever it likes. With one named field it stays a property of the format.

None of the probe body-constraint sets in this vocabulary changed when that field arrived, and that is the test of it: the sets were never the obstacle. media.serve’s catalogue probe requires a JSON assertion, and once a claimant can ask for JSON it can make one.

A probe carries who it is asked as

credential Meaning Against a live service with nothing held
none Anyone may ask Runs
operator The credential the operator holds for this service Unproven — never failed

The second is not a weakening. It is F3-R5 and F4-R7 applied honestly: a manifest cannot hold a credential until recipes arrive (F8), so a credentialed probe run against a live service with nothing to present has established nothing — and reporting that as a failure would say the service is broken when the runner is. Against a recording it runs like any other, and is reported as a proof against recordings and never as one against the service (F10-R6).

A probe is a request, unless the capability’s fillers listen on nothing

A probe asks a running service a question over the network, because that is how a service filling a capability is reached. quality.sync is the exception: its contract says a service claiming it listens on nothing, since it writes the profiles into each curator’s own configuration on its own schedule, so no request could reach it.

Its probe is a run instead: ADR-0048’s bounded run of the claimant image’s own program (a proof that runs the image’s own program), judged on exit and stdout_starts_with and asked as none. A claim binds it with run where every other probe takes request (ARCH-R283). The generated entry says which kind each probe is: a run probe’s requires lists the exit statuses that answer it under exit, in place of status, and stdout_starts_with as its body constraint.

A run probe is allowed only for a capability whose contract says its fillers listen on nothing, and quality.sync is that capability (ADR-0050). A run probe declared for any other fails generation, as a capability nothing declares does (ARCH-R309).

A probe asks what the service does, never what the library holds

A probe gates an install: F4-R6 refuses to install a service whose probes fail. So a probe that asks about the operator’s own data is a plugin nobody can install until they have put some there, and the author who wrote it will not find out — their own instance has data in it.

The failure is easy to write and reads as the stronger proof. At least one series looks like better evidence of serving a library than an answer shaped like a catalogue, and it is worse: an empty library is a library, and a fresh machine is where a plugin is installed.

So a probe is satisfiable by a service with nothing in it. json_array_min = 0 is how a claimant says the answer reads as a list without saying how long the list is; json_has_keys is how it says the same of an object.

The rule has a checkable edge, and it is worth having one: a count above zero is the only way an expectation can say something about how much the operator has, so a binding carrying json_array_min above zero or any json_at_least minimum above zero is refused. Everything else an expectation can say is about shape.

A contributed check is the opposite and may say exactly what a probe may not, because it reports on a running stack rather than gating an install — a check that fails on a fresh installation is a check doing its job, and the remedy it carries is the point of it.

A refusal or a redirect is evidence; a status alone usually is not

A binding is held to its probe: where the published probe lists body constraints, a binding whose expect carries none of them is refused, naming the probe and the constraints that would show it. The reason is that 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. ARCH-R105 asks the same of a manifest’s proofs, and the core holds no [[proof]] to it.

A guarded probe is the exception the rule leaves room for, and the reason is worth stating rather than looking like an inconsistency. A 401 is not something a port proxy can produce. An emptied container answers a refused connection or a gateway error; only an application that has a protected surface answers that it is protected. So "body": [] on a guarded probe is not a weaker expectation — it is one of the two cases where the status is the body’s job.

The other is a redirect to HTTPS, and for the same reason: a port proxy does not send a caller elsewhere, and only a server that routes by name answers plain HTTP with 301 or 308. proxy.front’s probe is that redirect, because it is all its bundled filler answers over plain HTTP, whatever the name and path, with an empty body (ADR-0051). A probe constraining only its status is a refusal or a redirect to HTTPS, and nothing else.

It is also the one probe every claimant can bind, which is why it is required almost everywhere: measured across the bundled stack, the anonymous read of the thing a capability is about is refused by Sonarr, Radarr, Lidarr, Bindery, Prowlarr, SABnzbd, Bazarr, Jellyfin, Audiobookshelf, Calibre-Web and Seerr, and answered by none of them. A media server that stopped refusing it would be publishing somebody’s library to the household network, and this is the probe that notices.

What is published, where

Artefact Asset on every release Read from the binary
This capability-vocabulary.json lemonfiber plugin capabilities --json
The extension points extension-points.json lemonfiber plugin extension-points --json
The manifest schema plugin-manifest.schema.json lemonfiber plugin schema

All three are generated, committed, and regenerated by CI with a diff failing the build (ARCH-R106, ARCH-R111, ARCH-R93). None is hand-written, for ADR-0014’s reason: a schema that is written is a claim about the server; a schema that is generated is a description of it.

Every read works with no network, no catalogue and no running stack, and states the generation it is reporting. An author asking what they may claim gets an answer from the binary they already have (F10-R8).

Validation

Rule Failure
Every provides entry is a core name or is namespaced with the plugin’s id Name given, with the two shapes
Every core name in provides names a capability this generation carries Name given, with the ones that exist
A name this generation removed Name given, with the generation it went in
Every core name in provides has exactly one [[claim]] Name given
Every [[claim]] names a capability in provides Both given
Every probe the capability declares is bound Probe and capability given
No binding names a probe the capability does not declare Probe given, with the ones it declares
A binding asks as its probe does, with run for a run probe and request for any other Probe given, with the kind it is
A binding’s status is one the probe permits Status given, with the set
A binding carries one of the body constraints the probe requires Probe given, with the kinds
A binding’s request.accept is one media type Value given, with what is permitted
Every key in a binding’s expectation names a place an answer could hold Key given, with what is wrong with it
Every binding names a recorded response that exists Path given
No binding asserts a count above zero — json_array_min, or a json_at_least minimum Probe and constraint named

Requirements

ID Requirement
ARCH-R106 The core capability vocabulary MUST be published as one machine-readable artefact generated from the types lemonfiber reads it with, MUST carry its own generation, and regenerating it MUST produce no diff, with CI failing if it does.
ARCH-R107 The set of bundled services declaring each capability MUST be generated from the stack manifest rather than restated, and generating a vocabulary carrying a capability no bundled service declares MUST fail.
ARCH-R108 The capability vocabulary, the extension points and the generated manifest schema MUST each be attached to every release under a stable name, and MUST each be readable from the binary with no network, no catalogue and no running stack, stating the generation reported.
ARCH-R109 A service claiming a core capability MUST bind every probe that capability declares to a request on itself, or to a run of its own image’s program where the probe is a run (ARCH-R309), an expectation the probe permits and a recorded response; a claim leaving one unbound, naming one the capability does not declare, or carrying an expectation weaker than the probe requires MUST be refused by naming the probe rather than by parse failure.
ARCH-R110 A bundled service MUST declare the capabilities it provides in the stack manifest, in this vocabulary, and a name the vocabulary does not carry MUST be refused by name.
ARCH-R136 A bundled service MUST prove each capability in its provides with one [[service.claim]] in the stack manifest, binding every probe to a request on itself, or to a run of its own image’s program where the probe is a run (ARCH-R309), an expectation the probe permits and a response recorded from its pinned image under recordings/<service id>/, by the rules a plugin’s claim is held to (ARCH-R109); a capability with no claim, a claim for one it does not provide, and a recording from any other image MUST each be refused by name.
ARCH-R137 Every bundled claim MUST be judged against its recordings on every change to the stack and before lemonfiber is tagged; a refuted probe MUST refuse the change and the tag, naming the service, the capability and the probe, and a probe that could not be judged MUST be reported unproven and MUST NOT be counted as demonstrated (F9-R2, F9-R5, ARCH-R122).
ARCH-R119 A capability’s probe MUST be satisfiable by a service holding none of the operator’s own data, so that a claim does not rest on what the operator has yet put there.
ARCH-R309 A capability’s probe MUST be a request, except that a capability whose contract says its fillers listen on nothing MAY declare a probe that is a run of the claimant image’s own program, performed as ARCH-R284 performs a run, judged on exit and stdout_starts_with alone and asked as none; quality.sync MUST be that capability, and generating a vocabulary declaring a run probe for any other MUST fail.