Contract: the web API on the wire
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
Part of the web API contract: 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. The requirements it describes are listed with the rest of the contract’s, under Requirements.
The details two clients must agree on
A requirement that states an obligation without stating its mechanism gets two implementations that both satisfy it and cannot talk to the same server. These are the particulars, fixed so that no client has to invent them.
The token
The header is X-Lemonfiber-Token. The binary prints the token when it starts
serving, and a client is given it by its caller — there is no discovery, no file to
read, and no default.
The address
There is no default port. The binary chooses a free one unless told otherwise and prints the whole address; a client is configured with that address rather than assembling one.
A host name is accepted only if it resolves to a loopback address. Refusing the word
localhost outright is the wrong trade: it is what an operator types and what a
printed address may contain. Refusing a name that resolves off loopback is the
protection that matters, and resolving before connecting is what provides it.
That protection is against a browser: a page the operator visits cannot read a
cross-origin response, but it can send a request the server acts on, and DNS
rebinding defeats a naive origin check. A page has no pin to offer and never
will, so for sdk-ts the rule stands unchanged.
A client that can be given a pin is in a different position. The companion is
deliberately not on this machine (ADR-0017),
and the identity question a loopback address answered by construction is answered
for it by the pinned certificate instead
(ADR-0025).
So the address rule is a condition rather than a prohibition: loopback needs no
pin, anything else needs one, and there is no third case. ARCH-R99 is written so
that the unsafe combination has no spelling — a non-loopback address is not a
setting that can be turned on and a pin forgotten, because the pin is what
produces the address.
The heartbeat
The server emits a comment line at least every 15 seconds when nothing else has been sent. A client treats the stream as broken once twice that has passed in silence, which tolerates one missed beat without pretending a dead connection is a quiet one.
Resumption
Every event carries an id. A client resuming sends the last one it saw as
Last-Event-ID, and the server replays from after it where it can, or restarts the
stream where it cannot. Either way everything the client still holds from before the
gap is stale until replaced — the resumption mechanism does not change what is
current, only what is retransmitted.
A job’s outcome
GET /api/jobs/<job>
DELETE /api/jobs/<job>
The other end of the accepting reply. It is not named for a command, because it answers no request the command line has: being answered with a name instead of an outcome is the web’s own arrangement, and this is the half that makes it an answer.
DELETE releases the name. For work with no ending of its own — a guard that holds until the
data location is lost — a browser has no interruption to send, so letting the name go is its
Ctrl-C, and what the container engine was already asked to do goes on exactly as it does when
a terminal is closed.
The standing is carried by the status, and the body is a document the client already parses either way:
| Standing | Status | Body |
|---|---|---|
| Still in flight | 202 |
the identical envelope the accepting reply carried |
| Finished | 200 |
the equivalent command’s machine-readable output |
| Stopped on a failure | the status the failure warrants — 404 for something named that the stack does not have, 400 for a request asked wrongly, 409 for work that found other work holding the stack, 500 otherwise |
the error envelope the failure renders |
| Ended before it finished | 200 |
the job envelope, saying whether the name was released or let go |
| Not a name this run issued | 404 |
a refusal, not the name repeated back |
Status rather than a field, because the alternative is a shape only this endpoint has — and a
second serialisation of an outcome the contract already describes is the drift ARCH-R47
exists to prevent.
Work that found other work holding the stack is none of the refusals above. Nothing it named
was missing, nothing about it was asked wrongly, and the machine did not fail: the stack does
one lifecycle operation at a time (B6-R8), and this one waited its turn and did not get it.
It is the one stopped job whose right answer is the same request sent again once the other
work is done, so it has a status of its own, 409. Told 500, a client words a failure of
the machine about a stack that was working and busy, and cannot do what B6 asks of it —
show that another operation is in progress (ARCH-R135).
Work ended before it finished has no outcome to give, and it ended one of two ways. A
client released its name, or nothing asked about work with no ending of its own for long
enough and it was let go. The two are different news to whoever comes back to the name:
the first is somebody’s decision, and the second is a guard that stopped guarding because
the screen watching it went away. So the answer says which, and neither is told as work
that finished or work still going (ARCH-R132).
The stream is not the mechanism. A client that reconnects sends the last id it saw, and a client connecting for the first time has none to send, so an event announcing the end reaches only a client that was already listening when it happened — which is not the client the accepting reply exists to serve. A tab closed mid-repair and reopened holds a name and nothing else, and this is what it does with it.
Names do not outlive the run that issued them. Work in flight does not survive the process doing it, so a record that outlived the run would describe jobs nothing is running; a name from an earlier run is therefore one this run never issued.
An action sent again
POST /api/actions/<name>
Idempotency-Key: <key>
A client that sent an action and heard nothing back cannot tell a request that never
arrived from an answer that never reached it. The safe thing for it to do is send the same
action again, and a stack that could not recognise the second send would restart twice or
pause twice. So an action request may carry an Idempotency-Key naming the attempt it is,
and a request without one is an attempt of its own (ARCH-R165). It serves the retry inside
one attempt and nothing longer (N1-R42, ADR-0020).
The same key sent again by the same caller while its attempt is remembered is answered
with what the first send was answered with: the same job name for work that runs on, and
the same status and envelope for an action answered on the spot, a failure included. The
action runs once. A second send that arrives while the first is still being carried out
waits for the first one’s answer rather than starting the action beside it (ARCH-R166).
The caller is whoever the guard admitted: the terminal’s token, the operator, a household
member, or a key by its name.
A request refused before its action is reached — a body it cannot read, an action it does not name, an argument refused, a caller not permitted — is not an attempt. Nothing ran, so nothing is kept, and the same request sent again is ruled on again.
Work that ends without an answer — stopped, or fallen over — is answered 500 with a
code of its own, to the first send and to every send waiting on it, and its attempt is
forgotten: it reached no answer to give again, so the same key sent afterwards runs the
action again. A send never waits on an attempt that will not answer (ARCH-R166).
A key names one attempt, so it names one action and its arguments. The same key sent
with another action or other arguments is refused with its own code at 400, and nothing
runs under it (ARCH-R167). Answering it with the first send’s outcome would hand a client
an answer to a request it did not make, and running it would run two attempts under one
name.
What is remembered is held in memory and nowhere else, and does not outlive the run, as the work it names does not. Each caller has their own, and how long and how many are the operator’s two settings, read again at every send so a change holds from the next one:
| Setting | Holds | Default | May be |
|---|---|---|---|
LEMONFIBER_IDEMPOTENCY_MINUTES |
how long an attempt is remembered from its first send | 30 |
a whole number from 1 to 1440 |
LEMONFIBER_IDEMPOTENCY_KEYS |
how many attempts one caller has remembered at once | 256 |
a whole number from 1 to 4096 |
Half an hour is the lease a job’s name is kept for, so a second send answered with a name
is answered while that name can still be redeemed. Past the count, a caller’s next attempt
lets go of that caller’s oldest answered one, so one caller sending many keys never pushes
out another’s. An attempt still being carried out is never let go, by age or for room:
letting it go would let its second send run the action beside it (ARCH-R168). Each
setting is changed as any setting is, and shown with where it came from (A4-R13).
A key is one to 255 visible ASCII characters, ! to ~, given once. Anything else — an
empty value, a longer one, a space, a character outside ASCII, the header given twice — is
refused with its own code at 400 before anything runs, because a key that cannot be read
the same way twice is one a second send could not be recognised by (ARCH-R169). The
header means nothing outside /api/actions/<name>.
The payload’s type
data differs by kind, so a client exposes it typed by its kind rather than as
an untyped value. Generated types make this ordinary rather than laborious: the kind
is the discriminator, and an untyped payload on the public surface means the
generation has not been used.
An instant
Every instant the web API carries is an integer: whole seconds since the Unix epoch,
1970-01-01T00:00:00Z, as a JSON number in a request, a response or an event, and as
its decimal digits in a link the core builds, such as the join link’s expires
(D6-R22) and the key link’s (C10-R18). There is no string form, no fraction and no
zone to read, so two clients compare two instants as numbers and agree. A command’s
--json writes instants alike, since the web API answers with the command’s own
envelope (ARCH-R46). A stamp a service wrote and lemonfiber passes on verbatim, such
as the one a container put on a log line, is that service’s text rather than an instant
of lemonfiber’s, and is labelled as such (ARCH-R274).
Related
- web-api — the rest of the contract, and its requirements
- web-api-getting-in — how a caller is admitted