Contract: the web API
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
The interface between lemonfiber, which serves it, and
lemonfiber-web, which draws it.
Satisfies: G1-R1, G1-R2, G1-R7, G1-R12, C6-R9, C6-R10, C6-R11, C6-R12
Why this is a contract
ADR-0011 put the web
surface in its own repo, which means the boundary between it and the core is a published
shape rather than a compiler check. That is the whole point: G1-R2 says no surface may
implement behaviour independently, and a client that can only ask and draw cannot break it
by construction.
The risk a contract carries is drift, so this one is built to make drift structurally hard rather than merely discouraged.
It is the envelope that already exists
Machine-readable output is already a stable interface (ARCH-R9, versioning):
{ "api_version": 1, "kind": "status", "data": { … } }
The web API does not introduce a second shape. Every endpoint answers with the identical
envelope the equivalent command emits under --json, byte for byte. A script piping
lemonfiber status --json and the web app fetching /api/status receive the same document.
This is the load-bearing decision in the contract, and it is worth being explicit about why.
A separate “web shape” would be a second serialisation of the same domain — two places to
change when a field is added, two sets of tests, and eventually two answers to the same
question, which is exactly what G1-R2 exists to prevent. One shape means the surface adds
no vocabulary of its own.
Reading
Each command that supports --json has one endpoint, named for the command, returning that
command’s envelope:
GET /api/forms GET /api/status GET /api/services
GET /api/checks GET /api/storage GET /api/logs?…
GET /api/requests GET /api/trace?… GET /api/stuck
GET /api/version GET /api/config?… GET /api/quality
GET /api/explain?… GET /api/backups GET /api/bundle/{name}
GET /api/front-door GET /api/provenance GET /api/catalogue
GET /api/outbound GET /api/stored GET /api/clients
GET /api/credentials GET /api/alerts
GET /api/space GET /api/bandwidth
GET /api/hosting GET /api/uninstall
GET /api/migration GET /api/held?… GET /api/held/{id}?…
GET /api/held/{id}/poster?… GET /api/held/{id}/backdrop?…
GET /api/history
GET /api/update?…
GET /api/news
GET /api/plugins GET /api/wiring GET /api/settings
GET /api/playing GET /api/watching?…
GET /api/askable?… GET /api/askable/{id}/poster?…
Query parameters mirror what the command takes, flag or argument. A command that gains one gains a parameter; one that gains an endpoint gained a command first.
/api/held/{id} is one title on a member’s shelf, named in the path by the id the shelf lists
it under: what it is, a series’ seasons and episodes, and where each is served at the guarded
front door beside the fingerprint the door presents (D11). It is read as the member whose shelf
it is, so a title outside their limits is answered as absent, the same answer a title the
household does not hold gets. /api/watching is what a member was part-way through and how far,
located the same way.
/api/held/{id}/poster and /api/held/{id}/backdrop are the two reads that answer bytes rather
than an envelope: the title’s picture, read as the member and passed on as an image/* body of
at most 2 MiB, marked Cache-Control: private (D11-R9). They exist for a browser, which can
neither present a session to the guarded front door nor pin its certificate, so it fetches the
picture here with its own token and draws it from memory. A title outside the member’s limits,
and a picture the media server does not hold, are answered as absent.
/api/clients takes none either, and for a different reason: what to watch on is the same answer on every machine, because the client landscape belongs to the platforms rather than to a stack. It reads nothing and asks nothing of the engine, so it answers where nothing is set up yet — which is when somebody deciding what to tell the house is most likely to ask. Naming a device would let a surface show one row and call it the answer, and the row most worth reading is the one saying a device is poorly served and what to do instead.
/api/credentials takes none, and it is half a word on purpose. What it answers with is
every credential the stack holds — what each is, what authenticates with it, where the value
lives and where it stands, and each grant letting it cross to a third-party plugin
(ARCH-R325) — and no value, because the shape it is built from has no field one could go
in. The two things that can be asked of a line of it are not offered here at all:
printing a credential over this door would put it through a browser’s cache, whatever proxy is
between and the log each of them keeps, and replacing one is a write a request could be forged
into making against the credential the stack is currently working on. Both are the terminal’s,
in front of the person who typed the confirmation. A parameter narrowing the list would be a
way to ask about one credential rather than the set, and the answer worth reading is the set —
the credential worth knowing about is usually the one somebody had forgotten was there.
/api/space takes none, and the absence is the point. What it answers with is the account of
where the disk went and an offer of what could be got back; there is no parameter choosing what
to reclaim, because that choice is never a caller’s to make. A torrent still seeding is named
with what removing it costs and left with the operator, and something the operator has already
asked to be left alone is not on offer at any level of fullness — so a narrowing parameter would
be a way to ask for exactly the two things nothing here will take.
The space action that answers the offer can be narrowed, and only within it: only names
categories of what the offer says costs nothing, so an operator agrees to each one apart and
never to a set chosen for them; anything the offer did not name is refused (ARCH-R289).
/api/bandwidth takes none, and the absence draws the same line the disk’s does. What it
answers with is the account of the line: what it was measured to carry, what the stack is being
held to, which side of the household’s day each download client says it is on, and whether each
is actually keeping to what it was given. Declaring a limit is the action of the same name, at
the door changes are asked for — a read that could set one would be a read that changes what
everybody in the house experiences, which is the distinction the two doors exist to draw.
/api/uninstall takes one, and it is required in spirit rather than in shape: which of the
four removals is being read. Naming none reads the one that removes nothing, because that is
the safe reading and the one a browser opening the page has not chosen anything by; a word that
names none of the four is refused rather than read as whichever the shape would default to,
since on this subject the default that would hurt is the one that reaches the library. It is a
read and never a removal — what it answers with is the listing every container, image and path
a removal would take, and the action of the same name is where an answer to that listing goes.
The two are one command read the same way twice, so what a browser agrees to is what it was
shown.
/api/update takes one too, and it names the object rather than narrowing the answer: which
of the two things that can be moved forward is being read — the services the stack runs, or
lemonfiber itself. They are one command because moving something forward is one word an
operator reaches for, and the object is the whole of what tells them apart; what=stack
answers with what each service would move to, how large the step is, and which of the steps
migrate state and so cannot be walked back, while what=self answers with where this copy
stands and the exact command for whichever tool owns it. Naming none is refused rather than
answered, and this is where it parts company with the removal above: that one has a reading
that takes nothing, and here neither object is the smaller case of the other — a page that
asked about the stack and was handed the binary has been answered a question it did not ask,
and the two answers do not even resemble each other. Both are reads whichever is named.
Nothing here replaces the binary at all, and moving the services is the action of the same
name, where an answer to what the read listed goes.
/api/hosting takes none, because what is hosted is a property of the machine rather than
of the caller. It answers with every long-running command that could be handed to this
machine’s service manager and what became of each — installed or not, running or not, and
where the words of a command with no terminal are being written. Naming one would let a
surface show a single row and call it the answer, and the row worth reading is the one
saying a guarantee the operator believes is in force is not.
/api/front-door takes none, because the question takes none: which one address the household
is given is derived from what the stack runs rather than asked for, so a parameter here would be
a way for one surface to be told a different door from another.
/api/requests answers with the household and what each member has asked for, the same
document lemonfiber household --json writes. Each request carries what a member’s own screen
needs to show it without asking anything else: the kind of title it is, the year that title came
out, when it arrived on the media server, and the identifier the media server holds it under,
which is the identifier /api/held names it by, so a request that has arrived can be found on
the shelf. A value that cannot be known is left out rather than written as an empty one: the year
until a request has been handed to the service that files it, and the arrival and the identifier
until the title is on the media server (ARCH-R161).
/api/news takes none, because what is new is decided by whoever reads it rather than by the
stack. It answers with the items a surface can mark as new, newest first within each kind: the
releases in the stack’s record, each by its version; the household’s requests, each by its
number; and the checks found wrong, each by its check and its onset (C1-R17). Each carries
the stack’s own words for it. The stack keeps no record of what anybody has seen, because what
one phone has seen is not what another has, and a stack that kept it would be keeping a
person’s reading habits. lemonfiber news --json is the same document.
/api/bundle/{name} is the one read that does not answer with an envelope. It answers with
the bundle itself, because a browser has no path on the host to be told and handing the file
over is the only form --out can take on a screen. The name is resolved beneath the bundles
directory rather than followed, so one carrying a path, or climbing out of that directory, is
refused by name.
What is playing
GET /api/playing GET /api/watching?…
The playing envelope lemonfiber playing --json prints: every session the media server is
playing now, each naming the member watching and what they are watching, so how many are
playing is read off the list rather than kept beside it. The operator, and a read or act
key, are answered with every session. A member, and a key scoped to one, are answered with
their own sessions and nobody else’s (ARCH-R156).
The narrowing is the command’s, not the reply’s. What runs names the member who asked, whatever the request named, as it does for the household row and the held shelf, so there is no path on which another member’s session is read and then left out.
What a member may ask for
GET /api/askable?… GET /api/askable/{id}/poster?… POST /api/actions/ask
/api/askable is a member’s search for titles the house does not hold yet, answered by the
core under that member’s limits (D4-R15), as lemonfiber household search --json answers.
It takes term, the words searched for, two to a hundred characters once trimmed and refused
outside that as WISH-1; after, the place a previous page said the next one begins at,
refused as WISH-2 where it is not one a page answered; and member, which the operator names
and a member’s own request has replaced with themselves, as the held shelf does. A member, the
operator and a key scoped to a member are answered; a read or an act key is not, because
every search is a live question to the request service and to the catalogue behind it.
- Where the titles come from. The core asks whatever fills
request.intake, over the contract where it speaks it and through the bundled adapter otherwise. The client asks the core and nothing else. - Which kinds. The kinds a running curator can deliver now, decided by the core and named in
the answer’s
kinds(D4-R4,D4-R18). Nothing to fetch with is refused asWISH-4. - Under whose limits. A title with no certification is looked up for the country the media
server’s own rating table is named for, which
identity.sourceanswers ascountry; one still uncertified is held to the member’s unrated policy and is never let through by default (D4-R19,D8-R5). A title outside the member’s limits is left out, never shown as unavailable (D8-R9). - One page at a time. A page holds the titles in the request service’s order and
next, the place the following page begins, absent on the last; no title is on two pages of one search (D4-R17). - What each title says. Its id, kind, title, year where known, and where it stands:
here,partly-here,on-its-wayoraskable(D4-R5,D4-R10). No title carries the location of its artwork; the core serves that itself (D11-R10). - What the member may ask for. Beside the titles,
asking: what their period allows and has left of each kind and when it next frees up, as the household read says it, andapproval, a household sentence saying whether what they ask for waits for whoever runs the house.
/api/askable/{id}/poster is the poster of a title a page offered, named by its id in the path
and its kind as a parameter, with member as the search takes it and answered to the same
callers. It answers bytes rather than an envelope, as the held shelf’s pictures do: an image/*
body of at most 2 MiB marked Cache-Control: private (D11-R10). The core fetches it from the
location the request service gave for that title, and only where that location lies beneath the
one catalogue address the account of what leaves this machine names, over HTTPS, with no
credentials, its host resolved and refused where it stands for a loopback, private or link-local
address, and no redirect followed; the member’s client is never sent to the catalogue. A title
outside the member’s limits is refused as WISH-3, as an ask is; one with no poster, a poster
that is not a raster image or is larger, and an operator who switched posters off
(LEMONFIBER_REACH_POSTERS, G8-R5) are answered as WISH-13.
ask takes id and kind, as a page of /api/askable named the title, seasons for a series
(none names every season not already here or on its way, D4-R11), name, which the operator
names and a member’s own request has replaced with themselves, and dry_run. It answers now,
never with a job, in the asked envelope lemonfiber household ask --json writes (D4-R16,
D4-R20):
- The rehearsal comes first. Under
dry_runit checks everything the ask would and sends nothing, answering what the ask would leave of the member’s allowance in titles of each kind, never as a size (D7-R15), and whether it would wait for approval. - The limits and the allowance are checked before the request service is asked. An id the
member’s search could not have offered them, outside their limits or unknown, is refused as
WISH-3, the same answer for both, so an ask reveals nothing a search would hide (D8-R9). - One ask per title. A title already on its way is answered
on-its-wayand nothing is created (D4-R10); one already here is refused asWISH-5(D4-R5). A title not out yet is asked for and says it will arrive once it is (D4-R6). - What it answers.
outcomeisasked,waitingoron-its-way;leavesis what is left of each kind afterwards, absent for a kind nothing limits;saidis the household sentence for all of it. - Sent once. A client sends every ask with an
Idempotency-Key, so an ask retried after no answer is carried out once (ARCH-R165).
Every refusal of any of the three carries a household sentence and a remedy beside its code, and names no
service, size, address or state of the stack (G4-R16):
| Code | Status | Refused because |
|---|---|---|
WISH-1 |
400 |
the words searched for are fewer than two or more than a hundred characters |
WISH-2 |
400 |
after is not a place a page answered |
WISH-3 |
404 |
no title this member may ask for goes by that id and kind |
WISH-4 |
409 |
the house cannot fetch that kind, or anything, right now |
WISH-5 |
409 |
the title is already here |
WISH-6 |
409 |
the member has asked for all their allowance lets them, until it frees up |
WISH-7 |
503 |
the house has no room for new things, told as the house’s and never as the member’s limit (D7-R15) |
WISH-8 |
503 |
the request service or the media server did not answer, with the operator told |
WISH-9 |
503 |
the house has no request service or media server to ask |
WISH-10 |
400 |
a season named is not one the title has, or seasons were named for a film |
WISH-11 |
400 |
the operator named nobody |
WISH-12 |
404 |
nobody in the household goes by the member named |
WISH-13 |
404 |
the title has no picture to show |
When a request is refused
The body of every refusal is the error envelope, the same one --json renders, and its
problem’s code names why the request was refused. The status says which kind of refusal it
is:
| Refused because | Status |
|---|---|
| What the request named, this product does not have | 404 |
| The request could not be answered as it was asked | 400 |
| The password offered at the door was wrong, or none is configured | 401 |
| Anything else about who is asking or where they asked from | 403 |
| Too many wrong answers at the door lately | 429 |
| Nothing about the request was wrong; the machine could not answer it | 500 |
A status groups refusals; the code tells them apart. Four different facts share 403:
- The request carried nothing this run admits. Signing in again helps.
- It said it came from somewhere this server is not.
- The caller proved who they are, and this is not theirs.
- The media server could not say whether the caller is still anybody.
Each of these has its own remedy, and a client that read them from the status alone would offer one remedy for all four. So a client decides what a refusal means from its code. It never decides from the sentence, which is written for a person and may be reworded.
That sentence is plain text, and so is every other sentence the core sends a client: a refusal’s summary and remedy, a reason, a report, and the meaning and remedy each code carries in the registry. None carries markup. A name is written as the name, with no backticks or emphasis around it, so a client shows the sentence exactly as it arrives and no client has to render a markup language to be read correctly. A capability contract’s refusals and reasons are held to the same rule, because the core passes them on.
A code is one of the core’s problem codes. It is stable, never recycled, and listed with every other code the core raises. The artefact lists every code a refusal may carry, with the status each is answered with (the refusals it lists), so an SDK generates the list rather than copying it. A refusal carrying no code, or a code the client does not know, is read by its status alone. That is what a client meeting an older or a newer server can still rely on.
The line between the first two is what the request was for. A word this product does not
explain is absent — the word is the whole of what /api/explain was asked for, and there is
no entry — while a parameter left out, given twice, or given a value the surface does not
offer is a request that could never have been answered as it stands. It is the same line the
write surface already draws between an action name nothing answers to and an argument it
cannot take, and the same reading ARCH-R72 makes of a job name this run never issued.
500 is reserved rather than incidental. A client told the machine failed will retry, and a
client told that about a word with no entry retries forever; worse, one that cannot tell the
two apart has to word a single message that is true of both, which is how “no entry” and “no
answer” reach an operator as the same sentence.
Live state
GET /api/events text/event-stream
The SSE event name is the envelope’s kind, and the payload is the envelope. Three
properties are required rather than incidental:
It is the same gather. G1-R12 requires concurrent surfaces to agree, and two
independent gathers are two chances to disagree — so the stream is fed by the gather that
already serves the dashboard, not a second one built for the web.
A silent stream is distinguishable from a broken one. Without a heartbeat, “nothing has
changed” and “the connection died twenty minutes ago” look identical to a client, which is
the same confusion Reading::Stale exists to prevent one layer down.
A resumed stream does not lie about what it missed. On reconnect a client may hold values
gathered before the gap. Those are Stale by definition, and must be presented as such
rather than as current.
What is newest rides the stream, and the items do not. A news event names the newest few
identifiers of each kind /api/news lists, when any of them changes and when a client
connects. A surface that marks what is new can then say whether anything is, and how much,
from the stream it already holds, and reads /api/news only on the screen that lists the
items — rather than reading three answers for every stack on every frame of every screen to
draw a mark on a tab.
An alert rides the stream as it happens. An alert event is sent when an alert starts and
again when it resolves, carrying what the dashboard’s alert says: what happened, what it means
for the household, what to do, how much it matters and which way it went. It also carries an
identity that names the alert rather than the event, the same on an onset and on the resolution
that ends it and different for the next time the same thing goes wrong, so a client can close
what it opened without comparing sentences. A client connecting is not sent the alerts already
said; the dashboard’s own list is where those are (ARCH-R162).
An alert names its notification kind, from a closed list. Beside its event kind, which is
open and grows with every check, an alert carries notification: one of problem,
completion, update, backup, request or member, the list the artefact publishes as a closed union. A new
kind is a contract change. GET /api/alerts carries kinds, the ones this stack raises, so a
client offers a switch for each of those and for nothing else (ARCH-R279, ARCH-R280,
ADR-0047).
A resumed stream replays the alerts it missed. A stream opened with Last-Event-ID is sent
every alert event raised after that id, oldest first, from the last 24 hours and at most the
last 200, before anything else, so a phone the platform woke an hour later hears what was said
while it slept. An id older than that is answered from the oldest kept, and the replay says
partial so the client knows it missed some (ARCH-R281).
A phone can ask to be woken. A phone that cannot hold the stream while it is closed
registers a UnifiedPush endpoint, and the core sends it a WebPush message, encrypted to the
phone, on every alert’s onset and resolution, carrying nothing but that the stack has something
to say. The phone then reads the stream over its own connection, so the distributor learns when
a stack spoke and never what it said. The endpoints are the operator’s to see and remove, at the
command line as here (ARCH-R282):
POST /api/push-endpoints GET /api/push-endpoints DELETE /api/push-endpoints/{name}
What is playing rides the stream. A playing event carries every session the media server
is playing now, in the envelope GET /api/playing answers the same credential with, and it is
sent again every 5 seconds, the pace a member’s stream sends what they are playing at. A client
that shows what is playing, a home automation’s sensor among them, follows it from the stream
it already holds rather than polling the read (ARCH-R178).
A line names the work that said it. A start, pull or step event said by work an
accepting reply named carries that name as job, at the top of the envelope beside kind and
data, and an event no job said carries none — a run at a terminal, the server starting:
{ "api_version": 1, "kind": "pull", "job": "9f2c1a7e04b3d815", "data": "Pulling sonarr" }
The name sits on the envelope rather than in data, so what a kind carries is the same
whoever said it, and a client ties each line to the start, fetch or walk it asked for by the
name the accepting reply gave it (B2-R19, D3-R14). The lines the container engine writes
while pulling images are pull events, for a fetch and for a start that pulls, and the
command line’s --json says them under the same kind (B2-R18, ARCH-R48). A start fetches
the images it lacks before it starts anything and then starts with nothing left to fetch, so
its pull lines all come before its start lines and a fetch that failed is said before
anything was started. A pull line is
state, as a start line is: a client connecting is given the newest, because the line says
where the work has got to and a fetch says hundreds of them. A step is a record, because a
step missed is a hole in the walk.
A member’s stream
The stream above is the operator’s view: the dashboard, the log lines, what setup is doing.
None of it is narrowed to anybody, so it is never handed to a member. A member, and a key
scoped to one, are admitted to a stream of their own instead. It carries their household row,
their held shelf and what they are playing, each as the read that answers it answers that
member, and nothing gathered for the operator (ARCH-R157).
It is the same stream in every other respect. It beats at the same interval, it resumes from
the same event id, and a value gathered before a gap is stale on it as on the operator’s. It
ends when the member is removed or their key is revoked, as the operator’s ends when the
operator’s credential does (ARCH-R158).
Getting in
How a caller is admitted, by the per-run token, a session or an integration key, and the limit wrong answers meet, is on its own page.
Writing
POST /api/actions/<name>
Arguments mirror the command’s. G1-R1 requires every action to be available from every
surface, and this contract adds the converse: the web API exposes nothing the CLI cannot
do. An action that exists only here would be a behaviour implemented by a surface.
Long-running actions return a job identifier and report progress on the event stream, so a browser tab that closes mid-repair does not orphan the work. The identifier is redeemable — see a job’s outcome — because a name that cannot be turned back into an outcome makes the reply an acknowledgement rather than an answer.
Rehearsing
--dry-run is one flag over every command, and the core decides for each command how it
answers it: a read runs as it always does, a command that changes something reports what
it would do and changes nothing, and a command whose effect cannot be known without
producing it refuses the flag with its reason — a walkthrough, a searching trace and the
disruptive checks. An action takes the same request as the argument dry_run and
answers it as its command does. A rehearsal writes nothing and answers with the envelope
the command’s rehearsal renders, and that envelope says it was a rehearsal; an action
whose command refuses the flag refuses the argument with the same reason (ARCH-R131).
The statement is required because nothing else on the wire can stand in for it. Several
actions answer an unconfirmed request with a reading of what they would do, and that
reading carries confirmed: false. So does a rehearsal of the same action. A client that
inferred one from the other would label a rehearsal as a cost waiting to be agreed to, or
a reading as something that had been tried and written nothing, and each is a different
sentence to show somebody.
A plugin, and what fills a capability
POST /api/actions/plugin-install POST /api/actions/plugin-update
POST /api/actions/plugin-remove POST /api/actions/plugin-run
POST /api/actions/plugin-prove POST /api/actions/wiring-fill
POST /api/actions/credentials-revoke
What is installed and what the stack wires to what are read as their commands answer
them, GET /api/plugins and GET /api/wiring. A record or a wiring that cannot be read
is refused with a code the artefact lists, and is never answered with an empty list: an
empty list says nothing is installed, which is a different sentence from this could not
be read (ARCH-R145).
The seven writes take what their commands take (ADR-0031):
| Action | Arguments | Answers |
|---|---|---|
plugin-install |
source, inputs, dry_run, offer, approved |
a job, then plugins |
plugin-update |
plugin, source, inputs, dry_run, offer, approved |
a job, then plugins |
plugin-remove |
plugin, dry_run, offer |
a job, then plugins |
plugin-run |
plugin, recipe, inputs, dry_run, offer, approved |
a job, then plugins |
plugin-prove |
plugin, dry_run, offer |
a job, then plugins |
wiring-fill |
capability, service, reason, dry_run, offer |
substitution |
credentials-revoke |
plugin, credential, dry_run, offer |
a job, then credentials |
The yes is the offer. Each rehearsal answers an offer built from what it read, and
the write takes that offer, builds it again from what is there now, and refuses where the
two differ, naming what moved. A bare confirm is no yes to any of the seven (ARCH-R146).
credentials-revoke ends one grant a third-party plugin holds, as lemonfiber credentials revoke does: the credential stops crossing to the plugin, whose services that read it
restart, and the plugin stays installed (ARCH-R324). It is a write over a grant, not over
a credential: it prints no value and replaces none, so it stays on this door while revealing
and rotating a credential stay at the terminal. A grant is given only by the install or
update offer that names it, through approved, as <setting>@<plugin> (ARCH-R323).
plugin-prove runs the plugin’s live proof again, as lemonfiber plugin prove does: a
pass clears every nonconforming record the plugin holds, and a failure keeps them and
names each case that failed (F14-R16, ARCH-R215).
A source is any the command takes: a catalogue name, a git repository or a path on
the machine (ADR-0036).
A git source is fetched over https from a public host; a name resolving to any address
not out on the internet, as the plugin manifest contract classes addresses, is refused,
every address it answers with checked when the fetch is made, the fetch connecting only
to those, and no redirect followed (ARCH-R152). The rehearsal names the revision it fetched, and the offer is
built from it.
A recipe runs on install or on demand, as its manifest says. An install recipe
runs inside plugin-install and plugin-update; a demand recipe runs only through
plugin-run, which names the plugin and the recipe. inputs carries each value a
recipe asks the operator for, as name=value, on plugin-install, plugin-update
and plugin-run alike. Until every one is given the act is refused as PLUGIN-34,
naming each input missing, so a client can ask the operator for exactly those; one no
recipe of that act asks for is refused the same way, naming it. An input is never part
of the offer and is never repeated back.
What a recipe would send is agreed to as itself. A rehearsal whose recipe would
carry a value to an external host lists each pair: the value’s name, where it comes from,
and the destination by name, never a resolved address. The write takes the approval of
each pair in approved, apart from offer, and refuses where one is missing; the offer
still covers the list, so a pair that changed is refused as a moved offer (ARCH-R147).
A pair releasing a value a service of the stack answered with is agreed to the same way,
and the rehearsal lists it with its release, the sentence the manifest gives for carrying
the value away from that service, and its from, the service it was read from (F8-R18). Any other pair to a service in the stack is
listed too and carries no approval: what it carries neither leaves the machine nor leaves
the service it came from, so there is nothing to agree to, and an approval written for it is
refused as one no pair asks for.
An approval is given to the act that carries it and to no other: a pair approved at
install is asked for again by a demand recipe that carries it, and by each run of it.
The rehearsal lists the recipe’s steps in order and names each adapter as lemonfiber’s
(ARCH-R148), and a call refused because an external name answered from inside the
network ends with a code of its own, apart from a network that failed (ARCH-R149).
Every recipe, pair, destination and adapter field is present on a rehearsal and on the
record, an empty list where there is none, so none is never confused with not read;
a pair’s approval is present exactly where the pair leaves the machine or carries a
release, and its release and from exactly where it carries one (ARCH-R150).
The installed plugins and the wiring each ride the event stream as their envelope: a
plugins event and a wiring event, sent when a client connects and whenever either
changes, an install that leaves a capability contested included (ARCH-R151).
Browsing what the catalogue offers is GET /api/plugins/catalogue, answered from the
verified index as lemonfiber plugin catalogue --json answers it, and listing nothing
the index’s signature does not cover (ARCH-R153).
Settings
POST /api/actions/settings-set POST /api/actions/settings-clear
A setting is a value a service’s environment reads from the operator rather than from its
manifest: one the core publishes for every service, such as the operator’s domain, or one
in a plugin’s own namespace, such as the countries an egress guard may exit from
(plugin-manifest). The settings live in
the core’s settings store, and GET /api/settings reads them as lemonfiber settings --json
does. Each setting carries its name; whose it is, the core’s or a plugin’s; each service that
reads it and the variable it is read into; the default its manifest declares; and the
operator’s value where one is recorded, so a value still at its default is told apart from one
somebody set to the same text (ARCH-R254).
The same read lists each secret a service’s environment names, with whose it is, the services
that read it, where its value came from (the operator, a recipe or a granted credential) and
whether one is held. It never carries the value: the shape it is built from has no field one
could go in, as the credentials read has none (ARCH-R256).
Only the operator is answered. A member, a key scoped to one, and a read or an act key are
refused, because a setting is how the machine is run and the household never sees it.
The core’s own settings are a closed list, and this is it. Beside the settings a service’s environment reads, the settings store holds the ones the core itself reads, each read back as the core’s:
| Core setting | What it holds |
|---|---|
LEMONFIBER_DEVICE_KEY_APPS |
The identities of the operator’s own builds a device key may come from (ARCH-R262) |
LEMONFIBER_DASHBOARD_SEARCH |
The address the dashboard’s web search box sends a search to, the terms appended to it: an absolute https or http address, https://duckduckgo.com/?q= where none is recorded (ARCH-R308) |
Every other LEMONFIBER_* key is lemonfiber config’s, read by GET /api/config. Each
command refuses the other’s names, naming the command that takes the name, so one name
has one writer (ARCH-R278).
The two writes take what lemonfiber settings set and lemonfiber settings clear take:
| Action | Arguments | Answers |
|---|---|---|
settings-set |
setting, value, dry_run, offer |
settings |
settings-clear |
setting, dry_run, offer |
settings |
settings-clear takes the operator’s value away, so the declared default is written again.
Each rehearsal names every entry the change regenerates and every service it restarts, and
answers an offer; the write takes that offer and refuses where what it would change moved since,
naming what moved, as the plugin writes do (ARCH-R146). Each write is journalled (ARCH-R255).
A value is written as exactly itself: nothing in it is substituted (ARCH-R230).
A secret is not written here. A secret a manifest names is entered as an input of the
install or update that first needs it, never repeated back, and is replaced afterwards at the
terminal, as every credential is
(ADR-0031).
Naming a secret to either write is refused, naming it (ARCH-R256).
The settings ride the operator’s event stream as their envelope, a settings event, sent when
a client connects and whenever one changes, and only to the operator, by the per-run token or
an operator session: never on a member’s stream and never to a key of any scope, so the stream
tells nobody what the read refuses them (ARCH-R257).
Setting up
GET /api/setup POST /api/setup/answer POST /api/setup/next
POST /api/setup/back POST /api/setup/apply POST /api/setup/recover
Named for the walk rather than for a command, because a wizard is one request read many
times: what it is standing on, an answer to that, and the movement between. apply is where
it stops asking and writes, and recover is what an apply that stopped part-way is answered
with — offered after the report has named what that apply had already written, so the choice
is made by somebody who has seen it.
These are writes and sit behind the same guard as the actions above. They are named here because the first-run walk is where credentials are entered, and an endpoint that takes one and is written down nowhere is the one nobody audits.
What a stack says it can do
GET /api/capabilities
Every other surface runs on the machine the stack runs on, so it is the same version by construction and never has to ask. A client on a phone is the first that can be older or newer than what it is talking to, and the first that can hold several stacks at once, each at a different version (N1-R11).
A version number does not answer the question. GET /api/version says which binary is
running; turning that into what this stack can do means a client carrying a table of which
release added which feature — a second copy of the changelog, on a different release cadence,
in a different repository. It goes wrong quietly: a client meeting a version its table does
not know either offers an action that fails when pressed, or hides one that works.
So the stack declares what it can do, and the client reads it rather than deduces it.
The payload is kind: "capabilities", whose data maps a capability name to its state:
{ "api_version": 1, "kind": "capabilities",
"data": { "capabilities": {
"/api/plugins": "available",
"/api/actions/wiring-fill": "available",
"/api/actions/pull": "unconfigured",
"/api/actions/invite": "unpermitted" } } }
A capability’s name is the path the surface serves the request at: /api/actions/<name>
for an action and /api/<name> for a read. The set is generated from the lists the surface
routes by, so a request the surface gains is a capability it declares with nothing else to
keep in step, and a client already holds these names, because they are what it calls. A read
and an action may share a word, and they are two requests — reading what an uninstall would
take is not taking it — so each has a key of its own. What a client shows for one is its own
words and not the stack’s.
Four answers, and the difference between them is the whole point — each is a different sentence to show somebody:
| State | Means | What a client says |
|---|---|---|
available |
This stack can do it, and this credential may | offers it |
unconfigured |
The stack has it; something must be set up first | offers it, and says what is missing |
unpermitted |
The stack has it; this credential may not | says it is not theirs to do, not that it is broken |
| absent | This stack does not have it | says the stack is too old, naming what would provide it |
Absence is the answer for a stack that is too old, and it needs no coordination: a stack that has never heard of a capability cannot name it, so a client meeting an older stack sees the same thing it would see if the capability were removed. That is why the shape is a map of what is rather than a list of flags, and why a client must treat an unknown key as something it does not understand rather than as an error.
The set is scoped to the credential, not only to the stack. What a household member may
do is already the core’s answer
(D6,
N3-R2), so a capability
set that ignored who was asking would be a second permission model — the thing N3-R2
exists to prevent. unpermitted is how that answer arrives, and it is deliberately
distinguishable from unconfigured, because “you may not” and “nobody has set this up” are
not the same news.
It says which stack is answering. Beside the capabilities the data carries stack, the
stack’s own identifier: the one pairing material carries
(N1-R62), minted once and kept,
the same across a change of address and a replacement of the certificate. A client holding
several stacks, or meeting one again at a new address, tells them apart by it. Neither the
address nor the certificate’s fingerprint can serve, because each changes in exactly the cases a
client most needs to recognise the machine, and the envelope’s host names the machine an
answer is about rather than the stack giving it. It is carried to every credential that may read
the capabilities, a member’s key included, because recognising a stack is not something a
credential’s scope can make anybody unentitled to (ARCH-R163).
It says whose credential asked. The data carries scope: operator, read, act or
member. A client that words itself for the household, or decides what to offer, reads the
scope from it rather than inferring it from which reads came back permitted, because a read
permitted to two scopes says nothing about which one is asking, and the set of reads a scope
admits can grow (ARCH-R179).
It is read, not pushed, and it can change under a client. Configuring storage turns
snapshots from unconfigured to available without the session ending. So it is an
ordinary read: it carries the age every other reading carries
(N1-R9), a client may retain
it and paint from it
(ADR-0019), and a client
that has not re-read it lately is holding an opinion rather than a fact.
A capability is not a promise the action will succeed. It says the action is offerable.
A repair may still be refused on its own terms, and that refusal is G4’s business, not
this endpoint’s.
What guards it
A writable API on loopback is reachable from any page the operator happens to visit — a page
cannot read a cross-origin response, but it can send a request the server acts on, and
DNS rebinding defeats a naive origin check. The CLI never had this exposure; nothing a web
page does reaches argv.
| Guard | Requirement |
|---|---|
| Bound to loopback, never all interfaces | C6-R1, C6-R3 |
| A per-run token, printed by the CLI at start, on every request | ARCH-R52 |
Origin and Host checked against the bound address |
ARCH-R53 |
| No proxying to an admin service | C6-R12 |
| Says plainly that it is unencrypted HTTP | C6-R6 |
| A loopback address, or a pinned certificate instead of one | ARCH-R60, ARCH-R99 |
The token travels in a header. Never a query parameter: URLs reach logs, history and referrers, and a credential that leaks into any of those has leaked.
Version skew
The client declares the api_version it speaks. Because the built app is embedded from a
pinned submodule (ADR-0012),
build.rs validates that declaration against the binary at compile time — the same
protection ARCH-R6 already gives the embedded stack’s schema_version. A mismatched pair
cannot be released.
The runtime check remains, because a browser may hold a cached older app. A mismatch there refuses plainly and says which versions are involved, rather than rendering a page whose fields have quietly changed meaning.
The details two clients must agree on
The particulars two clients must agree on to talk to the same server — the token’s header, the address, the heartbeat, resuming the stream, a job’s outcome, an action sent again, the payload’s type and how an instant is written — are on their own page.
Requirements
| ID | Requirement |
|---|---|
| ARCH-R46 | The web API MUST carry the same api_version envelope as machine-readable command output. |
| ARCH-R47 | A web API response MUST be identical to the equivalent command’s machine-readable output. |
| ARCH-R48 | The web API MUST NOT expose an action that is unavailable from the command line. |
| ARCH-R49 | Live state MUST be served from the same gather that serves the other surfaces. |
| ARCH-R50 | The event stream MUST emit a heartbeat, so a silent stream is distinguishable from a broken one. |
| ARCH-R51 | A resumed event stream MUST NOT present values gathered before the gap as current. |
| ARCH-R52 | Every request MUST carry a per-run token, delivered in a header and never in a URL; a request without it MUST be refused. |
| ARCH-R53 | Origin and Host MUST be checked against the bound address, and a mismatch MUST be refused. |
| ARCH-R54 | The client’s declared api_version MUST be validated against the binary at build time. |
| ARCH-R55 | An api_version mismatch at run time MUST be refused plainly, naming both versions, rather than rendering a partial view. |
| ARCH-R56 | The contract artefact MUST be generated from the types the server serialises, never hand-written. |
| ARCH-R57 | Regenerating the contract artefact MUST produce no diff, and CI MUST fail if it does. |
| ARCH-R58 | An SDK’s contract types MUST be generated from the artefact; hand-written response shapes MUST NOT be used. |
| ARCH-R59 | The per-run token MUST be sent in the X-Lemonfiber-Token header. |
| ARCH-R60 | A client MUST refuse a base address that does not resolve to a loopback address unless ARCH-R99’s condition is met, and MUST NOT refuse a loopback address for being named rather than numeric. |
| ARCH-R61 | The event stream MUST emit a heartbeat at least every 15 seconds, and a client MUST treat twice that in silence as a broken stream. |
| ARCH-R62 | Every event MUST carry an id, and a resuming client MUST send the last one it saw as Last-Event-ID. |
| ARCH-R63 | A client MUST expose a payload typed by its kind, never as an untyped value. |
| ARCH-R64 | The contract artefact MUST be published with every release, and an SDK MUST vendor it from an exact revision recorded beside the copy. |
| ARCH-R65 | Generating an SDK’s contract types MUST read the vendored artefact, and MUST NOT reach the network. |
| ARCH-R66 | Regenerating an SDK’s contract types MUST produce no diff, and CI MUST fail if it does. |
| ARCH-R67 | Generation MUST refuse an artefact whose api_version the SDK does not implement, naming both versions, and MUST write nothing when it refuses. |
| ARCH-R70 | A job identifier returned by a long-running action MUST be redeemable: a client MUST be able to learn from the server that the work ended and how it went, without having held a connection open since it started. |
| ARCH-R71 | Redeeming a job identifier MUST answer finished work with the equivalent command’s machine-readable output and work still in flight with the identical document the accepting reply carried; the two MUST be distinguished by status, never by a shape only this endpoint has. |
| ARCH-R72 | A job identifier the run did not issue MUST be refused as absent, and MUST NOT be reported as still in flight. |
| ARCH-R73 | The contract artefact MUST NOT describe a schema in a form whose meaning depends on which JSON Schema draft the reader applies, and SDK generation MUST refuse such an artefact, naming where it occurs, rather than generate from the half of it that it reads. |
| ARCH-R74 | A refused read MUST carry a status that distinguishes what the request named and this product does not have, from a request that could not be answered as it was asked, from a failure of the machine; the body MUST be the error envelope in every case, and the status of a failure of the machine MUST NOT be given to either of the others. |
| ARCH-R75 | The operator’s password MUST be exchanged for a session by one request and MUST NOT be required by any other. |
| ARCH-R76 | A session’s secret MUST travel in the same header the per-run token does, so the surface reads one credential header. |
| ARCH-R77 | A refusal a caller could correct by sending something else MUST be distinguished by its code from one nothing they could send would satisfy, and a refusal for too many failed attempts MUST say how long is left. |
| ARCH-R78 | The API MUST expose the set of capabilities the stack can perform, each named by the path the surface serves it at, and a client MUST NOT derive that set from a version number. |
| ARCH-R79 | A capability MUST be reported as available, present but unconfigured, or present but not permitted to this credential; a capability the stack does not have MUST be absent rather than reported as false. |
| ARCH-R80 | The capability set MUST be scoped to the credential that asked, so that it carries the core’s existing answer about what a household member may do rather than a second one. |
| ARCH-R81 | A client MUST treat a capability name it does not recognise as one it does not understand, and MUST NOT refuse the payload for containing it. |
| ARCH-R82 | The capability set MUST be readable again within a session, and MUST carry when it was read as any other reading does. |
| ARCH-R99 | A client MUST refuse a base address that is not loopback unless it was given a certificate pin for that stack, and MUST enforce that pin during the TLS handshake so that no request is written to a peer it has not verified. A client MUST NOT offer any means of reaching a non-loopback address without a pin, or of weakening verification (ADR-0025). |
| ARCH-R129 | A definition in the contract artefact MUST NOT take a name an SDK’s generator writes itself, and SDK generation MUST refuse such an artefact, naming the definition and the kind that carries it, rather than emit one name describing two shapes. |
| ARCH-R131 | Every action whose command reports a rehearsal under --dry-run MUST take that rehearsal as the argument dry_run; given it, the action MUST write nothing and MUST answer with the envelope the command’s rehearsal renders, and that envelope MUST say it was a rehearsal in a field other than confirmed, so that a rehearsal and an unconfirmed reading are told apart. An action whose command refuses --dry-run MUST refuse dry_run with the reason the command gives (F1-R2, ARCH-R47, ARCH-R48, N13-R10). |
| ARCH-R132 | Work ended before it finished MUST be answered, when its name is redeemed or released, with whether the name was released or the work was let go because nothing asked about it, and MUST be told apart from work that finished, work that stopped on a failure and work still in flight (N23-R13). |
| ARCH-R133 | The contract artefact MUST describe the body every web API route takes, the setup routes’ included — the answer to each question setup asks, and the choice a recovery takes — generated from the types the server reads a request into, so that a client builds a request from the artefact rather than from the server’s source (ARCH-R56, G1-R14). |
| ARCH-R134 | The contract artefact MUST publish every action the web API takes, with each argument it takes, the argument’s type and the consent the action asks for before it writes, generated from what the server reads; an SDK MUST generate the actions it offers from that list, so that an action or argument the core adds reaches a client as a regenerated diff (ARCH-R47, ARCH-R48, ARCH-R58). |
| ARCH-R135 | Work stopped because other work held the stack MUST be answered with 409, and MUST NOT be given the status of a failure of the machine, so that a client can say another operation is in progress (B6-R8) and offer the same request again once it is done. |
| ARCH-R138 | Every refusal the web API answers with MUST carry the error envelope as its body, and its problem code MUST name why the request was refused. Refusals that share a status and call for different remedies MUST carry different codes. A client MUST decide what a refusal means from its code and MUST NOT decide it from the sentence. A client MUST read a refusal that carries no code, or a code it does not know, by its status alone. |
| ARCH-R139 | The contract artefact MUST list every code a refusal may carry, with the code’s registry name, the status it is answered with and its description. An SDK MUST generate its list of refusal codes from that list and MUST NOT keep one of its own. |
| ARCH-R140 | The web API MUST answer GET /api/news with, for each of updates, requests and problems, the items newest first, each with its identifier — a release’s version, a request’s number, a check and its onset (C1-R17) — and the stack’s own words for it, and the response MUST be identical to lemonfiber news --json (ARCH-R47). |
| ARCH-R141 | The event stream MUST carry a news event naming, for each kind ARCH-R140 lists, the identifiers of its ten newest items, sent when a client connects and whenever any of them changes. |
| ARCH-R142 | Work refused because the consent it was given names an offer or a listing that has since moved MUST end with a problem code the artefact lists among its refusals, with the status that work is answered with when its name is redeemed, so that a client can re-offer rather than report a failure (N2-R6, A5-R13, ARCH-R138). |
| ARCH-R145 | The web API MUST answer GET /api/plugins and GET /api/wiring with the envelopes lemonfiber plugin installed --json and lemonfiber wiring --json answer, and a record or a wiring that cannot be read MUST be refused with a problem code the artefact lists, and MUST NOT be answered with an empty list. |
| ARCH-R146 | The actions plugin-install, plugin-update, plugin-remove, plugin-run, plugin-prove and wiring-fill MUST take dry_run and offer, MUST refuse a bare confirm as consent, and MUST end with a listed problem code when the offer they were given has moved. |
| ARCH-R147 | A rehearsal whose recipe would carry a value to an external host, or carry a value by a pair releasing it, MUST list each pair as the value’s name, its origin and the destination by name, never a resolved address, and a released pair with its release and the service it was read from; the write MUST take the approval of each such pair as an argument apart from offer, and MUST refuse where any is not approved. |
| ARCH-R148 | A rehearsal MUST list the steps its recipe would run, in order, and MUST name each adapter a step uses as lemonfiber’s. |
| ARCH-R149 | A call refused because an external name resolved to a loopback, private or link-local address MUST end with a listed problem code apart from the one a failed network ends with. |
| ARCH-R150 | Every recipe, pair, destination and adapter field MUST be present on a rehearsal and on the installed record, an empty list where there is none, except a pair’s approval, which MUST be present exactly where the pair carries a value to an external host or carries a release, and its release and from, which MUST be present exactly where the pair carries a release. |
| ARCH-R151 | The event stream MUST carry a plugins event and a wiring event, each with its envelope, sent when a client connects and whenever the installed record or the wiring changes. |
| ARCH-R152 | A git source named to plugin-install or plugin-update MUST be fetched over https from a host none of whose addresses is one the plugin manifest contract classes as not out on the internet, every address checked when the fetch is made, the fetch connecting only to those and following no redirect, and a source refused so MUST end with a listed problem code. |
| ARCH-R153 | The web API MUST answer GET /api/plugins/catalogue with the plugins the verified catalogue index lists, as lemonfiber plugin catalogue --json answers, and MUST NOT list an entry the index’s signature does not cover. |
| ARCH-R215 | The web API MUST answer POST /api/actions/plugin-prove, taking plugin, dry_run and offer, with a job and then the plugins envelope, running the live proof lemonfiber plugin prove runs, and a passing proof MUST clear the plugin’s nonconforming records while a failing one MUST keep them and name each failed case. |
| ARCH-R156 | The web API MUST answer GET /api/playing with every session the media server is playing now, each naming the member watching and what they are watching, as lemonfiber playing --json answers. A member, and a key scoped to one, MUST be answered with their own sessions only, narrowed by the command that runs rather than filtered from every session. |
| ARCH-R157 | A member, and a key scoped to one, MUST be admitted to the event stream narrowed to them: it MUST carry their own household row, their held shelf and what they are playing, each as the read answering it answers that member, and MUST NOT carry anything gathered for the operator. |
| ARCH-R158 | A member’s stream MUST emit the heartbeat, resume and mark values gathered before a gap as stale as the operator’s does (ARCH-R50, ARCH-R51), and MUST end when the member is removed or their key is revoked. |
| ARCH-R159 | The contract artefact MUST list every read the web API serves, each with its path, the query parameters it takes and whether each may be given more than once, and the kind it answers with or that it answers with a file, generated from the tables the surface routes and refuses reads by rather than written beside them. |
| ARCH-R160 | Each action the artefact publishes as callable by a key MUST also say whether calling it again with the same arguments leaves the stack as calling it once did, judged by the state the stack ends in rather than by whether the work is done again. |
| ARCH-R161 | Each request in the household document GET /api/requests and lemonfiber household --json answer with MUST carry the kind of title it asked for, the year that title came out, when it arrived on the media server, and the identifier the media server holds it under, as GET /api/held names it. The year MUST be absent until the request has been handed to the service that files it, and the arrival and the identifier MUST be absent until the title is on the media server, never present as null. |
| ARCH-R162 | The event stream MUST carry an alert event when an alert starts and when it resolves, carrying what happened, what it means, what to do, its severity and which way it went, and an identity that is the same for an onset and the resolution that ends it and differs for each recurrence. |
| ARCH-R163 | The capabilities answer MUST carry the stack’s own identifier, the one pairing material carries (N1-R62), to every credential it answers, a key scoped to a member included, and MUST NOT carry the address, the certificate’s fingerprint or the envelope’s host in its place. |
| ARCH-R164 | The rehearsal of each action the artefact publishes as callable by a key and as taking dry_run MUST answer an offer built from what it read. A call carrying that offer MUST be refused, with a problem code the artefact lists, where the offer built from what is there now differs, and a call carrying no offer MUST act as it would without one. Each key_callable entry MUST say whether the action answers an offer. |
| ARCH-R165 | The web API MUST read an Idempotency-Key header on an action request as the name of the attempt it is, and MUST carry out an action request without one as an attempt of its own. |
| ARCH-R166 | An action request carrying the key of an attempt the same caller sent and the web API still remembers MUST be answered with what that attempt was answered with — the same job name, or the same status and envelope, a failure included — and MUST NOT run the action again; one arriving while that attempt is still being carried out MUST wait for its answer. Work that ends without an answer MUST be answered 500 with a problem code the artefact lists, to the first send and to every send waiting on it, and its attempt MUST be forgotten. A request refused before its action is reached MUST NOT be kept as an attempt. |
| ARCH-R167 | An action request carrying a key the same caller already sent with another action or other arguments MUST be refused with a problem code the artefact lists, at 400, and MUST NOT run. |
| ARCH-R168 | Attempts MUST be held in memory only and per caller, each for the minutes LEMONFIBER_IDEMPOTENCY_MINUTES names from its first send, 30 where it names none, and up to the count LEMONFIBER_IDEMPOTENCY_KEYS names, 256 where it names none, both read at each send; a caller’s attempt past that count MUST let go of that caller’s oldest answered attempt and of no other caller’s, and an attempt still being carried out MUST NOT be let go, by age or for room. |
| ARCH-R169 | An Idempotency-Key MUST be one to 255 visible ASCII characters given once; an action request carrying anything else under that header MUST be refused with a problem code the artefact lists, at 400, before anything runs. |
| ARCH-R170 | SDK generation MUST refuse a $ref in the contract artefact that it cannot resolve to a definition in the vendored copy, naming the reference and the file it appears in, and MUST write nothing when it refuses. |
| ARCH-R173 | The contract artefact MUST describe the event stream: every event kind GET /api/events emits, with the schema of its payload, and the heartbeat’s interval, generated from the types the stream serialises. |
| ARCH-R174 | The contract artefact MUST carry one example of each kind, each read and each action, drawn from the core’s golden test fixtures, and CI MUST refuse an example that does not validate against its schema. |
| ARCH-R175 | The core MUST publish its code registry as contract/codes.json, each code with its family, name, severity, exit code, HTTP status, summary, meaning, remedy and the version it first appeared in (since), generated from the registry it raises from; reference/error-codes.md MUST be generated from that file, and CI MUST refuse either when it differs from what generates it. |
| ARCH-R176 | release-contract MUST attach contract-diff.json to each release, naming every kind, definition, read, action, refusal and event kind that release’s contract artefact added, removed or changed against the previous release’s. |
| ARCH-R177 | The core MUST generate contract/web-api.openapi.json, an OpenAPI document of the web API, from the contract artefact, and CI MUST refuse it when it differs from what the artefact generates. |
| ARCH-R178 | The event stream MUST carry, to the operator and to a read or act key, a playing event with every session the media server is playing now, in the envelope GET /api/playing answers that credential with, sent when a client connects and again every 5 seconds, the pace a member’s stream sends what they are playing at. |
| ARCH-R179 | The capabilities answer MUST carry the scope of the credential that asked, one of operator, read, act or member, and a client MUST read the scope from it rather than infer it from which reads are permitted. |
| ARCH-R218 | The invitation envelope MUST carry join, the join link, where D6-R22 gives one, and unjoinable, the sentence D6-R24 requires, where it gives none and the invitation is not a rehearsal. |
| ARCH-R219 | POST /api/session MUST accept claim beside a member’s name and password (D6-R25). It MUST refuse a claim on an invitation that is not open with 401 under one code, whether the token is unknown, spent, lapsed or declined; MUST refuse a password shorter than the minimum under a code of its own naming the minimum (D6-R26); MUST count a claim against the attempts a sign-in is counted against; and MUST remain the one route that answers without a token. |
| ARCH-R220 | An envelope MUST NOT carry a drawn QR code. A surface MUST draw each code from the address, link or text the envelope carries, exactly as given (G1-R15). |
| ARCH-R223 | Every sentence the core sends a client over the web API or a capability contract (a refusal’s summary and remedy, a reason, a report, and each code’s meaning and remedy in the registry) MUST be plain text carrying no markup, and CI MUST refuse a core sentence written with a code span or emphasis. |
| ARCH-R254 | The web API MUST answer GET /api/settings as lemonfiber settings --json answers, with every setting in the core’s settings store: its name, whose it is, each service that reads it and the variable it is read into, the declared default, and the operator’s value where one is recorded; only the operator MUST be answered, and a member, a key scoped to one, and a read or an act key MUST be refused. |
| ARCH-R255 | POST /api/actions/settings-set and settings-clear MUST take what lemonfiber settings set and lemonfiber settings clear take; each rehearsal MUST name every entry the change regenerates and every service it restarts and answer an offer, the write MUST refuse where what it would change moved since, naming what moved, and each write MUST be journalled. |
| ARCH-R256 | The settings read MUST list each secret a service’s environment names, with whose it is, the services that read it, where its value came from and whether one is held, and MUST NOT carry a secret’s value; neither settings write MUST take a secret, and naming one MUST be refused, naming it. |
| ARCH-R257 | The settings MUST ride the operator’s event stream as a settings event, sent when a client connects and whenever one changes, only to the per-run token and an operator session, and MUST NOT be sent on a member’s stream or to a key of any scope. |
| ARCH-R258 | POST /api/device-keys MUST take name, public_key (a P-256 SubjectPublicKeyInfo in base64url), attestation (an object tagged by platform: {"platform": "android", "chain": […]}, the key attestation chain as each certificate’s DER in base64url, the key’s own certificate first, or {"platform": "ios", "object": …}, the App Attest attestation object’s CBOR in base64url, attested with the SHA-256 of exactly the bytes public_key decodes to as its client data hash, hashed once) and password from an operator session, and MUST answer the device-key envelope with the name, the public half’s fingerprint, its backing (strongbox, enclave or tee) and the registration time; a missing attestation, one that does not verify, one placing the key in software and one naming an app the core does not trust MUST each be refused with a code of its own listed among the artefact’s refusals (C10-R23). |
| ARCH-R259 | GET /api/device-keys MUST answer device-keys, listing each device key by name, fingerprint, backing, registration time and last use, and DELETE /api/device-keys/{name} MUST revoke one; both MUST be refused to a member session and to a key (C10-R26). |
| ARCH-R260 | POST /api/device-keys/{name}/challenge MUST answer device-challenge with a fresh challenge and when it expires, from an operator session for a registered device key (C10-R24). |
| ARCH-R261 | POST /api/keys MUST accept device, challenge and signature in place of password, the signature being ECDSA P-256 over SHA-256 of the line-feed-ended UTF-8 lines lemonfiber-mint/1, the challenge, the name, the scope, the purpose and claim or key, in base64url; the artefact MUST publish that form, and the refusals of C10-R25 MUST each carry a code of its own listed among the artefact’s refusals. |
| ARCH-R293 | The keys envelope MUST carry proved_by on each key minted with a device key’s signature, naming that device key, and MUST leave it out of a key minted any other way (C10-R33). |
| ARCH-R294 | The version envelope MUST carry attestation_status, the instant the build’s snapshot of Google’s attestation status list was taken, and the refusal of an attestation whose chain that snapshot lists as revoked or suspended MUST carry a code of its own listed among the artefact’s refusals (C10-R34). |
| ARCH-R262 | The identities of the operator’s own builds MUST be the core setting LEMONFIBER_DEVICE_KEY_APPS, read by GET /api/settings and written by settings-set and settings-clear as every setting is: a comma-separated list of android:<package>@<SHA-256 of the signing certificate, in hex> and ios:<Team ID>.<bundle ID>. A value naming an identity in neither form MUST be refused, naming it; the official identities the build ships MUST be read alongside and MUST NOT be part of the value (C10-R30). |
| ARCH-R274 | Every instant the web API reads or writes, in a request, a response, an event or a link the core builds, MUST be a whole number of seconds since the Unix epoch: a JSON integer in a body, and its decimal digits in a link’s query. It MUST NOT be written as a string, with a fraction, or with a zone or an offset; a command’s --json output MUST write instants alike; and a stamp lemonfiber passes on verbatim from a service’s own output MUST NOT be presented as one of its instants. |
| ARCH-R278 | A core setting MUST be one the list under Settings names; lemonfiber settings and the settings writes MUST take a core setting and no other LEMONFIBER_* key, and lemonfiber config MUST take every other LEMONFIBER_* key and no core setting; a name either command takes, given to the other, MUST be refused naming the command and the name that take it. |
| ARCH-R279 | The artefact MUST publish the notification kinds as a closed union, problem, completion, update, backup, request and member, and every alert event MUST carry exactly one as notification beside its event kind: problem for every condition at warning or worse, at onset and resolution; completion for work the operator asked for finishing; update for an update to the stack or lemonfiber being available; backup for a backup that succeeded; request for a household member’s request newly waiting on the operator; and member for an invitation accepted, declined or lapsed. request and member alerts MUST ride the operator’s stream and wake only endpoints an operator session registered, and MUST NOT reach a member’s stream; the core MUST NOT raise an alert addressed to a household member (B5-R1). |
| ARCH-R280 | GET /api/alerts MUST carry kinds, the notification kinds this stack raises as it is configured, each from the union ARCH-R279 publishes. |
| ARCH-R281 | A stream opened with Last-Event-ID MUST be sent, before any other event, every alert event raised after that id, oldest first, from the last 24 hours and at most the last 200; where the id is older than the oldest kept or unknown, it MUST send those kept and mark the replay partial. A stream opened without one MUST be sent no past alert (ARCH-R162). |
| ARCH-R282 | An operator session MUST be able to register a UnifiedPush endpoint for one device with POST /api/push-endpoints, taking name, an https endpoint, and the WebPush p256dh key and auth secret, and to list them with GET /api/push-endpoints and remove one with DELETE /api/push-endpoints/{name}; the command line MUST list and remove them too. On every alert’s onset and resolution the core MUST send each registered endpoint a WebPush message encrypted under RFC 8291 whose content says only that the stack has something to say, MUST forget an endpoint its distributor answers 404 or 410, and MUST refuse an endpoint that is not https or whose name another holds, naming it. A member session and a key MUST NOT register, list or remove endpoints. Every registration and removal MUST be journaled. |
| ARCH-R288 | A finding a read leaves unverified because checking it disturbs something MUST carry disruptive: what checking it disturbs, in the stack’s words; bounded (whole seconds the disturbance is bounded to, or absent where the stack bounds none); and only (the check or group a diagnose action takes to run it); and the diagnose action MUST accept that only as given. |
| ARCH-R289 | The space action MUST accept only, a list of categories the offer it answers names as costing nothing, and with the offer’s name MUST reclaim those categories alone; a category the offer does not name MUST be refused, naming it, with nothing removed. |
| ARCH-R290 | A trace that stopped at monitored where asking the indexers would tell nothing at the quality from nothing at all MUST carry searchable: what the search spends, in the stack’s words, and that it covers the curator’s wanted list at the quality in force; a trace that would spend no search MUST NOT carry it. The search action MUST accept disruptive absent or true, and the artefact MUST NOT publish a default it refuses. |
The artefact
The shapes the surface exchanges are generated from the server’s own types into one artefact every SDK consumes. What it carries, the refusals it lists and how it reaches an SDK are on their own page.
Related
- web-api-artefact — the generated artefact, and how it reaches an SDK
- web-api-getting-in — how a caller is admitted
- web-api-wire — the particulars two clients must agree on, instants included
- sdk-ts — the TypeScript client implementing this contract
- versioning.md —
api_versionand the envelope it belongs to - design-tokens.md — the other contract
lemonfiber-webconsumes - ADR-0011 — why the boundary is a contract
- ADR-0012 — how the built client reaches a machine
- C6 — the security policy this sits inside