← The specification

Contract: plugin.toml — [[service]]

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

Status: Accepted

Part of the plugin.toml contract: each service a plugin runs, declared field by field. The requirements this page describes are listed with the rest of the contract’s, under Requirements.

[[service]] — what runs

Declared in the one service model every Compose entry is written from (ADR-0043). A first-party plugin, a third-party plugin and stack.toml declare a service in the same fields, with the same types and the same validation, and whether a plugin is first-party decides whether it may be installed and which credentials reach it, never what it may say (ARCH-R225). The set of fields is the set of things a service may ask for of the machine, which is the substance of ADR-0021: it is wider than one shape, and still closed and typed.

One or more, and the reason for more is not generality. A plugin is one thing an operator installs and one thing they remove, and that thing is often two containers: a media server and the reader of its watch history, a service and the sidecar that indexes it. The two differ in exactly the way the format exists to record — one faces the household on lan, the other is an operator surface with no business being reachable from the sofa; one is important, the other enhancing — so a format permitting one forces the author to put both halves on the wider tier, or to publish two plugins an operator then keeps in step by hand. Neither is a thing to ask of somebody, and the second is how a household ends up with an admin page on its network.

What more than one costs is three questions a single service never raised, and each is answered rather than deferred:

  • Which service a hostname is about. [[wiring]] names it, and is an array rather than a table for that reason alone.
  • Which service a proof or a contributed check asks. Each names it, and must where the plugin declares more than one.
  • Which service answers for a capability. Exactly one may declare a given core name — see below.

Service ids are unique within the manifest as well as across the stack, which was not a rule anything could break while there was one of them. Two services sharing an id is not a collision with anything installed; it is one name for two containers, and every rule that reaches for a service by that name would reach one of the two and say nothing about the other.

[[service]]
id     = "plex"
name   = "Plex"
image  = "docker.io/plexinc/pms-docker"
digest = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
tag    = "1.41.3.9314"
port   = 32400
bind   = "lan"
health = { kind = "http", path = "/identity", timeout_s = 90 }
criticality = "important"
media_types = ["movies", "tv"]
takes_data  = true
provides    = ["media.serve", "plex:direct-play"]
config_path = "/config"
shape       = "render"
memory_mib  = 600

[[service.mount]]
from = "transcode"
to   = "/transcode"

[service.env]
PLEX_CLAIM = { setting = "plex:claim-token", default = "" }

A service that has no network of its own, with a memory ceiling:

[[service]]
id            = "transmission"
name          = "Transmission"
image         = "lscr.io/linuxserver/transmission"
digest        = "sha256:0d2a7c9be1a3f4e6c8b5d7f9e1a3c5b7d9f1e3a5c7b9d1f3e5a7c9b1d3f5e7a9"
tag           = "4.0.6"
port          = 9091
bind          = "loopback"
criticality   = "core"
takes_data    = true
user_mode     = "puid-env"
mem_limit_mib = 512
network       = { via = "network.egress-guard" }

[service.env]
TRANSMISSION_WEB_HOME = "/usr/share/transmission/public_html"
Field Type Required Notes
id string ✔ Unique within this manifest, across the stack and across every installed plugin. A collision is refused, naming both.
name string ✔ Human-facing
image string ✔ Registry path, without tag or digest
digest string ✔ sha256:…. What actually runs.
tag string ✔ The human-readable version the digest corresponds to. Recorded and shown; never resolved.
port integer The port it is published at on the host: its primary UI/API port. Omitted for a service that publishes nothing.
bind enum ✔ if port loopback | lan. A tier, not an address, and every port the service publishes is published on it — see ports.
publish array of tables Each further port it publishes on its tier: port, on the host (✔), and listens, inside the container (default port). Only where port is declared.
health table As stack.toml. Absent means lifecycle waits on container state only.
criticality enum ✔ As stack.toml. critical only with shape = "egress-guard" — see below.
reaches string Where this service’s own requests go, as stack.toml declares it. Both this and asks_for or neither; a first-party plugin’s service declares both (F2-R10, ARCH-R270).
asks_for string What it asks for there, as stack.toml declares it. Never blank (ARCH-R270).
media_types array Which media types it handles, in stack.toml’s vocabulary. Drives root-folder seeding, which is how a plugin’s service is pointed at the library the stack already fills.
takes_data bool true if it needs the data root mounted at /data, from the operator’s storage setting. Default false.
provides array The capabilities this service claims (F4-R1). Core names from the published vocabulary; a plugin’s own MUST be namespaced (F4-R4). At most one service of a plugin may declare a given core name — see below.
config_path string Where inside the container its configuration directory is mounted. Default /config.
config_ro bool true to mount the configuration directory read-only. Default false.
mount array of tables Each further path mounted from its own mount tree, ./mounts/<id>, which lies apart from its configuration directory: from (✔), to (✔) and ro (default false) — see mounts.
tmpfs array Absolute paths given scratch space in memory, empty whenever the container starts.
env table Its environment: a variable’s name to one of four typed kinds of value — see environment.
user_mode enum image | puid-env | uid. Who the container runs as. Default image, and uid where the service speaks a contract or is confined — see who it runs as.
posture enum standard | confined. How tightly the container is held, as declared. Default standard. confined only where the plugin’s own CI runs the image under it.
memory_mib integer The memory it expects to need, in MiB. An estimate, summed into a form’s footprint (B1-R18) and never written as a limit.
mem_limit_mib integer The most memory the container may hold, in MiB, written as its limit.
network table { via = "<capability>" }: it runs inside the network of the service that fills that capability, and has none of its own — see network.via.
api table The in-process adapter lemonfiber reaches it through, in stack.toml’s shape. kind and key_source from the published set. Honoured only in a first-party plugin the bundle assembles — see below.
listens integer ✔ if api or speaks, or where it answers on a port other than port The port it answers on inside the container: where port is published to, and where lemonfiber, the services that ask and the proxy reach it. Default port.
speaks array Each capability contract the service answers as an adapter, written capability@major from contract/capabilities/index.json (ARCH-R200). A service that speaks a contract names no api: it is asked one way. See capability-contracts.md.
upstream table Only where it speaks: each field of the upstream.json lemonfiber writes for this adapter, to { secret = "<id>" } naming a secret the plugin holds, usually one it mints (ARCH-R317).
fronts string ✔ if speaks The plugin’s own service this adapter stands in front of: the upstream it answers its contracts for. It names another service of the same plugin, one that speaks no contract, and its conformance recordings are taken against the digest that service is pinned by.
native string The API this service answers the stack’s other services in, by the name those services know it by (jellyfin, plex, emby). Declared on the upstream an adapter fronts, where other services reach that upstream directly rather than through the adapter: a request service is set up against a media server in the media server’s native API (F14-R26, ARCH-R221).
replaces string An id the bundle reserves, of the service this service moves in place of: the same container name, configuration directory and data, so an install moves the bundled service rather than running beside it (ADR-0042 §4). It names the service’s own id, and only that service may take the reserved id, port and hostname (ARCH-R222). Only a first-party plugin’s install honours it (F14-R29), and the service keeps the stack’s setting, secret and credential names for that id, so the move migrates none (ARCH-R273).
shape enum egress-guard | render. The one privileged shape lemonfiber writes for this service, from a closed set; each names who may declare it, and the operator approves it apart from the offer — see shapes (F14-R12, F14-R21, ARCH-R237, ARCH-R238).

The image is named by digest

F3-R8 requires a referenced image to be pinned. A tag is not a pin: it is a name the publisher can repoint, so the image reviewed and the image run can differ with nothing in the manifest changing. That is the same reasoning ADR-0009 applied to workflow actions — whoever can move the ref changes what every consumer runs, unreviewed at the consumer — and it applies with more force here, because the consumer is an operator’s home machine rather than a CI runner.

So digest is what lemonfiber writes into the generated entry, and tag is carried beside it as the readable name. A manifest with a tag and no digest is refused. Declaring a digest that does not correspond to the tag is not detectable without reaching the registry and is not treated as though it were: the digest is what runs, and the tag is a label on it.

Ports — the tier is lemonfiber’s, the numbers are the manifest’s

A service declares loopback or lan. It cannot declare an address or a host-facing interface, because a service that could write its own address could place an admin surface on the LAN without touching anything C6 inspects. lemonfiber renders the tier to an interface for every service alike, and the two-tier policy stays a property of the system rather than a request a manifest makes.

The numbers are the manifest’s. port is where the service is published on the host and listens where it answers inside its container, so an image answering on 8080 is published at 8085 by declaring both, and one answering where it is published declares port alone. A service publishing more than one port declares each further one in [[service.publish]], on the same tier: a door serving plain HTTP and TLS, a proxy answering on 80 and 443.

port    = 8096
bind    = "lan"

[[service.publish]]
port = 8920

A port another service already publishes is refused, naming the port and both services (ARCH-R226). A service that speaks a contract is also published at its listens port on the loopback interface, at a port the engine chooses, which is where lemonfiber reaches it.

config_path — where its own directory lands

What a service reaches of the host is fixed: the single data mount, the service’s own configuration directory, and its own mount tree (ARCH-R227). That is what makes “what can this plugin reach” answerable from the format. It does not follow that the configuration directory must land at /config, and assuming it did made a whole class of image uninstallable.

/config is a LinuxServer.io convention, not a standard. Of the twenty bundled services, five keep their configuration somewhere else: Homepage and Seerr at /app/config, Caddy at a file under /etc/caddy, Jellyfin and Audiobookshelf needing a second path beside the first. A plugin whose image is any of those shapes would be generated a container mounting a directory the application never reads — installing successfully, passing its health probe, and losing everything it had written the moment the container was replaced.

So the target is data. The source is still lemonfiber’s, and there is still exactly one of it. What is permitted is narrow:

  • a single absolute path, and not /;
  • not /data and nothing beneath it, which would be a second mount over the library wearing a different name;
  • no .., and no interpolation.

config_ro = true mounts it read-only, for an image that reads its configuration and has no business changing it: the guarded door reads its routes there and writes nothing.

Mounts — from its own tree, and nowhere else

A second path beside the first — Jellyfin’s /cache, Audiobookshelf’s /metadata — is a mount whose source lies in the service’s own mount tree, a directory lemonfiber keeps beside its configuration directory rather than inside it:

[[service.mount]]
from = "cache"
to   = "/cache"

[[service.mount]]
from = "Caddyfile"
to   = "/etc/caddy/Caddyfile"
ro   = true

from is a relative path inside the service’s own mount tree, a file or a directory, and lemonfiber writes it as ./mounts/<id>/<from>. An absolute path, a .. or an interpolation is refused, naming the mount, and so is a source lying inside another of the service’s sources. to is an absolute path and not /; on a service that takes data it lies outside /data; and no two of the service’s mounts share a target. So a further mount widens nothing a reader has to judge: it is a place lemonfiber gave the service, landing where the image reads it.

The tree is apart from the configuration directory because a container writes its configuration directory. A source inside it would be a path the container could replace with a link, and the engine follows a link whenever it mounts, including when it restarts the container itself after the machine boots, when nothing of lemonfiber’s runs first. A source in a tree no container sees, except through that one mount, is a path no container can change; and because no source lies inside another, what a container writes through one mount never reaches the path of the next.

tmpfs reaches nothing of the host at all, and its targets are held to the same rules. It is scratch space in memory, empty whenever the container starts and writable by whoever the container runs as. The door keeps its working files there, so nothing it writes outlives it.

A service’s state lives under its own two directories and nowhere else (ARCH-R228). There is no volume the engine keeps on a service’s behalf: state somewhere the operator cannot see is state a backup, a move and the question “what can this reach” all miss. The proxy keeps its certificates and its own state in two such volumes today (caddy_data, caddy_config), so the in-place move copies each into the proxy’s mount tree once, mounts them from there, and leaves the volumes as they were, so that undoing the move finds them.

There is no field by which a mount reaches any other path on the host. In particular there is none for the engine’s socket, which is root on the host, for a first-party service as for anybody else’s (ARCH-R239).

Starting files — what it starts with

An image often needs a file before its first start that it would not write for itself: a download client’s settings with its folders already set, a quality sync’s guides, a dashboard’s layout, the proxy’s routes. A plugin ships such files in its own source, under config/<service id>/, and lemonfiber writes each into that service’s configuration directory at the same relative path (ARCH-R271):

plugin-sabnzbd/
├── plugin.toml
└── config/
    └── sabnzbd/
        └── sabnzbd.ini      written to ./config/sabnzbd/sabnzbd.ini
  • Materialised, as every file lemonfiber writes for a stack is. Each is recorded by content, and one the operator has changed is never overwritten without a diff and confirmation (F1-R4).
  • Bounded. At most 64 files and 1 MiB in all per service. A starting file is configuration a reviewer reads, not a payload.
  • No symbolic link, anywhere in the tree, and no path outside config/<id>/ of a service the plugin declares. A link would be a write to wherever it points, decided by the plugin and followed on the operator’s machine.

Any plugin may ship them, first-party or not: a substitute that needs a starting file stands in for a part that has one on the same terms.

A secret in a starting file

An upstream that reads its key from its own settings file is given it there. A starting file names a secret the plugin holds as {{secret:<id>}}, and lemonfiber writes the value in its place; {{secret:<id>|pbkdf2}} writes the salted PBKDF2-HMAC-SHA512 form a service that keeps only a hash of its password reads, as @ByteArray(<salt>:<hash>) (ARCH-R316):

# config/sabnzbd/sabnzbd.ini
api_key = {{secret:upstream-key}}
  • Only the plugin’s own. A placeholder names a [[secret]] whose of is one of the plugin’s own services. A credential that crosses is another service’s and stays out of this plugin’s files.
  • Held as a secret is. A file holding one is readable by the service’s owner alone, and it is recorded, diffed and shown with the placeholder where the value is.
  • Text, and one syntax. A file with a placeholder is UTF-8, and any other form, or a secret the manifest does not declare, is refused naming the file and the placeholder.
  • A few, and only through no link. A service’s files hold at most 16 placeholders in all: each is written at install and again whenever its secret is replaced, and the hashed form is a hundred thousand rounds. A file holding one is written only where no link stands on the way to it, since the directory is one the container writes.
  • Replaced where it stands. The service rewrites its own settings once it runs, so a replaced secret is not written from the template again. lemonfiber keeps the files as shipped, finds the line a placeholder is on by the text around it, and replaces the value there alone, keeping everything the service wrote; a file where that line is gone is refused by name, and the old value stays in force (ARCH-R318).

Environment — four kinds of value

[service.env]
VPN_TYPE              = "wireguard"
SERVER_COUNTRIES      = { setting = "gluetun:countries", default = "Netherlands" }
DOMAIN                = { setting = "domain", default = "home.local" }
INDEXER_URL           = { url_of = "indexer.search" }
UN_SONARR_0_URL       = { url_of = "library.curate", files = "tv", native = "servarr" }
WIREGUARD_PRIVATE_KEY = { secret = "wireguard-key" }
Value Written as
A string, an integer or a boolean A constant: the value exactly as declared
{ setting = "<name>", default = <constant> } The operator’s value for that setting, or the default where none is recorded
{ url_of = "<capability>" }, with files and native where needed http://<service>:<port>: the service the ask reaches, at the port it listens on
{ secret = "<name>" } The value of the [[secret]] the manifest declares by that name, in a file of the service’s own and never in the project

A variable’s name is upper-case letters, digits and _, and does not start with a digit. TZ, PUID and PGID are lemonfiber’s: it writes the operator’s time zone into every entry and the pair where user_mode asks for it, and a manifest naming one is refused (ARCH-R229).

Nothing a manifest writes is interpolated. Compose substitutes ${…} from the stack’s environment file in any value, quoted or not. Every constant, every setting’s value and every secret is written with $ doubled, so it reaches the container as exactly itself, and no value a manifest declares is written as a reference to that file (ARCH-R230). A value that could name a variable there would be a field for reading whatever the file holds.

A setting is the plugin’s own, or one the core publishes. A plugin’s own is namespaced with its id, as its own capabilities are (gluetun:countries), and a stack service’s with the service’s id. The core publishes two, the same for every service: domain, the operator’s domain, and lan-host, the address the household reaches the machine at. A setting in any other namespace is refused, naming it (ARCH-R231).

Settings live in the core’s settings store, and the operator reads and changes them from the command line, the web surface and the companion’s operator screens alike, through lemonfiber settings and GET /api/settings. Each change is journalled and regenerates the entries that read it. A secret lives in the secret store, never in the settings store (ARCH-R248). Neither is the stack’s environment file: the first run of the release that stops reading the stack’s static Compose files reads that file once, files every value the assembled parts and the published settings read from it under their setting or secret, journals each, and lemonfiber does not read the file for them again. A part’s later move in place keeps those names (ARCH-R273), so it migrates nothing. The file stays the one lemonfiber config reads and writes, for lemonfiber’s own keys such as DATA_ROOT and PUID (ARCH-R249).

A URL is an ask. url_of names a core capability, as an [[ask]] does, and is settled, shown and counted as one (F14-R22 to F14-R25): the reading says what it would reach, and a substitution rewrites it. It asks for one filler and never for each, because a variable holds one address, so a variable naming a capability its service asks each of is refused. files narrows the ask to the filler that files that medium, which is how a service told about each curator in its own variable names each one: one url_of for each, by the medium it files, never by its id. Two variables naming one capability and medium, or a variable and an [[ask]] naming them, are one ask. While nothing fills it, the variable is left out and the ask is reported unfilled (F4-R9). It never names a service: that would be a link by name, which a plugin may not introduce (F4-R12, ARCH-R232).

An ask reaches the adapter, unless it says otherwise. The core speaks contracts, so what fills a capability is, by default, the service that speaks its contract. A service that talks to the upstream in the upstream’s own API says so with native, naming the API the upstream declares (native = "servarr"), and its ask then reaches the service the adapter fronts; a filler whose upstream declares no such API leaves the ask unfilled, naming the API. Where the filler speaks no contract, as the stack’s own services do, the ask reaches the filler itself (ARCH-R253).

A secret is declared, held, and written apart. { secret = "<name>" } names a [[secret]] the manifest declares: a value lemonfiber mints, one the operator enters, one its recipe captures, or a credential F14-R18 or F14-R20 lets cross to it. lemonfiber writes it to an environment file of the service’s own, readable by the owner alone (0600), names that file in the entry, and never writes the value into the project. Every rehearsal, reading and diagnostic shows the variable and the secret’s name, never its value (ARCH-R247). A crossing credential is read from where it is held whenever the entry is written, never copied (ARCH-R277), and the journal keeps a secret’s fingerprint, never its value (ARCH-R276).

Who it runs as, and how tightly it is held

user_mode Written as
image No user: the image decides
puid-env PUID and PGID set to the operator’s uid and gid, for an image that drops to them itself
uid The operator’s uid and gid as the container’s user

The PUID/PGID pair is a LinuxServer.io convention, and setting it on an image that ignores it is a silent no-op that reads like a security control. So the manifest says which kind of image it is, rather than lemonfiber asserting something about an image nobody checked. A service that speaks a contract is built for lemonfiber’s, reads a key written readable by the operator’s uid alone, and is uid (F14-R27, ARCH-R233).

posture Written as
standard No privilege gained after start; every kernel capability dropped but CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID and KILL, the six an image needs to start as root, give its files to the operator and drop to them, and what its shape adds; at most 1024 processes
confined All of standard, with a read-only root, no kernel capability at all, user_mode = "uid" and no shape. Its own network translates no address off the host (ARCH-R243).

standard is every service’s, the stack’s own and every plugin’s, first-party and third-party alike, and it is what lemonfiber already writes for a plugin’s service (ARCH-R234). confined is for an image that holds a credential administering another service, as lemonfiber’s own do.

Posture is declared, and proved where the plugin is built. A plugin’s CI runs each of its images under the posture its manifest declares, standard included, and a manifest declares confined only where that run passes under it. So whether an image survives six capabilities, or a read-only root and none, is answered where the image is made. The stack’s own images are run under standard by the media stack’s CI, the CI that already runs them to record their claims, before the version that writes them ships (REPO-R93). lemonfiber writes the posture declared and refuses nothing at run time on account of it (ARCH-R250).

standard binds no port below 1024. None of its six capabilities is NET_BIND_SERVICE, so a service that answers on a low port listens above 1024 and publishes the low port to it. The proxy listens on 8080 and 8443 and publishes 80 and 443 to them, and its Caddyfile names both (http_port, https_port). An image whose binary carries a file capability standard does not hold cannot start under it at any port, and Caddy’s image carries NET_BIND_SERVICE on its binary, so the guarded door and the proxy run an image their own repositories rebuild from Caddy’s with that capability removed (ADR-0045).

mem_limit_mib is a ceiling and memory_mib an estimate, and they are two fields because they answer two questions (ARCH-R235). The estimate is summed into what a form would need and is never enforced: a figure that fits a media server idling would kill it transcoding. The ceiling is written as the container’s limit, for a service whose upper bound is part of what makes it safe, such as one that answers a household page and must not be able to exhaust the machine.

Inside another service’s network — network.via

network = { via = "network.egress-guard" }

Some services must have no network of their own. A torrent client inside the egress guard’s network namespace loses connectivity when the tunnel drops, rather than falling back to the home address (C2-R12). network.via asks for that, by capability (ARCH-R236):

  • the service is written into the network namespace of the service that holds the tunnel: the filler, or where an adapter fills the capability, the upstream that adapter fronts, which takes the egress-guard shape. It has no network of its own;
  • it starts only once that service reports healthy, which is the one start-order edge the format writes;
  • the ports it declares are published on that service’s entry, on its own tier, since a container with no network of its own publishes nothing;
  • it is an ask of the capability, settled, shown and counted as every ask is, so choosing another egress guard moves the service inside the one chosen.

The capabilities a service may take via are a closed set, and it holds network.egress-guard alone. A service may not take via a capability it provides. Starting the service starts the capability’s filler first and waits until it is healthy, whatever profile the filler sits in. That is the one start-order edge allowed to cross a profile (B1-R14), because the client and the guard are two plugins and so two profiles.

The client never learns the forwarded port from the guard itself: the core asks the guard’s filler for it over network.egress-guard every minute while the stack runs (from the hosted watch loop, and from the dashboard’s gather while a surface is open), when the stack starts and after every diagnosis whose VPN check ran, and sets it on the client over download.torrent when it changes (capability-contracts, ARCH-R251). No script is mounted into the guard, and no endpoint is opened for it.

Shapes — the only route to a device or a privilege

Shape Written as Who may declare it
egress-guard NET_ADMIN added, and the /dev/net/tun device The upstream an adapter of the same plugin fronts, where that adapter speaks network.egress-guard; a plugin’s one service where it provides network.egress-guard itself, honoured only where the bundle assembles the plugin (F14-R12); in the stack, the service that provides network.egress-guard
render The host’s /dev/dri device, and the group that owns it on the host added to the container’s groups A service that provides media.serve, or the upstream an adapter of the same plugin fronts, where that adapter speaks it

A shape is a name, never a list: no manifest names a kernel capability, a device or a group (ARCH-R237). The render group is read from the host’s device rather than declared, because its id differs from machine to machine and a manifest naming one would be naming a number it cannot know. Where the host has no /dev/dri, as under Docker Desktop or on a machine without a GPU, render is written as nothing and the reading says the service runs without hardware transcoding.

The reading names every service taking a shape and what the shape gives it, and the operator approves each apart from the offer, written <shape>@<service> (F14-R21, ARCH-R238).

provides — what it can do, in the vocabulary

F4-R1 has services declare what they can do as named capabilities so that wiring can ask for a capability rather than name a service. A plugin’s service declares them here, exactly as a bundled service declares them in stack.toml.

A core name comes from the published vocabulary (F4-R2) and means the contracted thing that vocabulary defines; claiming one and failing its probes is a verification failure and the plugin is not installed (F4-R6). A plugin’s own capability MUST be namespaced with the plugin’s id — komga:opds — and is inert until something asks for it (F4-R4).

The vocabulary is published as capability-vocabulary.json and is answerable from the binary, so an author asking what they may claim asks the tool rather than a document. A core name here with no [[claim]] block binding its probes is refused: F4’s whole posture is that a claim is demonstrated, not asserted, and a name in a list asserts. A capability the same service speaks is demonstrated by its contract instead: the live pass at install and its recordings are the evidence, and it carries no [[claim]].

One service answers for a core capability, and a plugin’s own may be shared

Something asks for a core capability by name and exactly one service answers. Two services of one plugin declaring media.serve is not a choice an operator could make — they installed one plugin — so there is nothing for them to resolve and nothing for F4-R8 to put in front of them. It is two answers to one question, and it is refused when the manifest is read, naming the capability and both services.

A plugin’s own namespaced capability is not held to this. It is inert: nothing asks for it and no published contract defines it, so two services both declaring plex:direct-play is two true statements rather than a contest. Refusing it would be refusing something with no consequence, which is the defect a stand-in gate is warned against in the other direction.

api names an adapter lemonfiber already has

A plugin’s service may declare api in the stack manifest’s own shape (api): kind names one of the adapters lemonfiber implements and key_source where the service’s credential is read. It is honoured in one place: a first-party plugin the bundle pins as assembled, whose one service is a stack service and is reached through that in-process adapter as the stack’s own are; the release whose bundle installs the part deletes that adapter. An install or update of a plugin naming api is refused, naming service <id>.api (ARCH-R272), as a third-party replaces is (F14-R29); every other plugin speaks a contract instead (F14-R3).

The set is fixed and published as contract/adapters.json, generated from the kinds and key sources lemonfiber implements (F8-R13, ARCH-R143). A plugin cannot add to it: no field lets a manifest supply an adapter (F8-R11), and a kind or key_source outside the set is refused, naming it and the set (F8-R12). A service declaring api declares listens too, as the stack’s own services do (ARCH-R144).

A service declaring no api is operated generically, as F1-R10 and F1-R11 promise for a service lemonfiber does not know: whatever asks for a capability it fills is told that nothing here can register it there, rather than being left unwired without a word.

critical comes with the egress guard’s shape

critical means its failure has consequences outside the machine, and in the bundled stack exactly one service holds it: the egress guard, whose failure exposes the home address. It is not a severity a contributor assigns to their own work: the classification drives how failures are reported and how hard lemonfiber tries to stop the operator proceeding. So it is allowed with shape = "egress-guard" and nowhere else, whoever wrote the plugin, and a substitute egress guard is as critical as the guard it replaces. A service declaring it without that shape is refused, naming the value, the shape it needs and the four available without it (ARCH-R97).

The fields that are absent, and why

Some of these are fields stack.toml carries for the stack alone, and the rest are what a Compose entry could say and the model does not. A manifest carrying one is refused by name (ARCH-R84), rather than having it ignored:

Field Why a plugin may not declare it
grants Kernel capabilities, by name. No manifest, the stack’s included, names a capability, a device or a group: a service that needs one declares a shape, which lemonfiber writes, so nothing a manifest writes can widen what it is given.
depends_on An ordering edge, by name. The one edge the format writes is network.via’s, to whatever fills the capability it names, because a container inside another’s network cannot start before that network exists; it is the edge that keeps torrent traffic inside the tunnel (B1-R14). Any other edge between services is a source of failures nobody can attribute, and one naming a service is a link by name.
[[wiring]] The bundle’s choices of filler and links by name — by, asks, filled_by, to: who is chosen for whom, and who names whom. Not the [[wiring]] below, which is spelled the same and says something else: hostname, dashboard_group and service, naming where lemonfiber puts this plugin’s service on the proxy and the dashboard. One is a link between two services; the other is one service’s own address. Since ARCH-R127 made the plugin’s a list too, the spelling no longer tells them apart and the file it is written in is what does. A plugin declares what it can do in provides, is asked for by whatever already asks, and asks in [[ask]], by capability only. In particular it cannot introduce a by-name link, which is F4-R12’s third clause: a by-name wiring is the operator’s exception to make and a plugin naming another service is reaching into wiring it does not own.
host_managed Native-mode lifecycle is the operating system’s (B2-R15). A plugin cannot install a system service.
profile Assigned, not declared: by the bundle for a first-party plugin, and plugin-<id> for any other (what lemonfiber writes).
environment Free-form variables. A free-form pair is how a manifest would configure its way past what the format says it does, and a value carrying ${…} would read the stack’s environment file, credentials included. A service’s environment is env instead, where every value is one of four typed kinds and nothing is interpolated.
ports, volumes, networks, network_mode, devices, cap_add, user, privileged, entrypoint, command Compose’s own keys. Each is what lemonfiber writes from the typed fields above or never writes at all, and a manifest naming one is naming the output rather than declaring the service.
last_release An abandonment signal maintained by the people reviewing the bundled pins, in the bundle beside each pin (F2-R14). Self-reported by a plugin it would signal nothing.
  • plugin-manifest — the rest of the contract, what lemonfiber writes from it, its validation and its requirements
  • stack-manifest — the vocabulary a plugin’s service is declared in
  • bundle — the ids a first-party replaces may name, and the profile its services sit in
  • ADR-0043 — the one service model every Compose entry is written from