Fleet mesh¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: none, retired with Core.
Every fleet host serves an anonymous status card about itself, watches its peers, and serves the cached observations behind a capability. The write side requests the apex role-flip through the same git-anchored flow the health worker uses, and accepts topology announcements that are hints, never commands. The design rationale — why the reads are cached-only, why announcements verify instead of believe, why the takeover route corroborates — is Fleet; this page owns the wire contract.
Reading the aggregate needs fleet.read; requesting a takeover needs fleet.takeover;
announcing needs fleet.announce. None of the three sits in any role bundle. Every
executed example runs against a live daemon — see
how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /fleet/status |
— | — | — |
| GET | /fleet/peers |
— | fleet.read |
— |
| GET | /fleet/monitor |
— | fleet.read |
— |
| GET | /fleet/hosts/{town} |
— | fleet.read |
— |
| POST | /fleet/takeover |
— | fleet.takeover |
— |
| POST | /fleet/announce |
— | fleet.announce |
— |
| GET | /derived |
— | derive.read |
— |
| GET | /derived/{kind} |
— | derive.read |
— |
Cap is the required capability (— = public, no token); a — in Cmdlet is reached directly, no wrapper (the callers are peer daemons, the monitor, and operators' HTTP tools). Paths are relative to /v1/api; the cross-cutting contract is on the API reference index. None of the rows is Write-flagged: the read routes serve cached files, and the write routes persist runtime job/announcement records only — the takeover's enactment goes through git, which is the one topology authority.
The status card — anonymous self-description¶
GET /v1/api/fleet/status serves this host's card with exactly these fields:
town, role, nowUtc, bootUtc, bootId, uptimeSeconds, imageDigest,
schemaVersion, syncAgeMinutes, version — and nothing else. The absence is
contract, not accident: no takeover-MR coordinates, no token names, no peer data ever
appear on an anonymous surface. town and role are empty strings on a host booted
outside the fleet channel (a dev boot); syncAgeMinutes is null when sync is
disabled. All instants are ISO-8601 UTC.
Response 200:
{
"town": "torneg", "role": "primary",
"nowUtc": "2026-07-23T12:00:00.0000000Z",
"bootUtc": "2026-07-23T09:15:00.0000000Z",
"bootId": "0b7e2f6a-…", "uptimeSeconds": 9900,
"imageDigest": "registry.gitlab.com/…@sha256:dc0605e9…",
"schemaVersion": "0.11.0", "syncAgeMinutes": 2.5, "version": "0.1.0"
}
The aggregate — cached observations, fleet.read¶
GET /v1/api/fleet/peers serves every peer's last-observed record plus this host's
own card, from the runtime cache only — the handler performs no outbound request, ever,
so it answers instantly even with every peer dark. Each peer record carries the fetched
card plus observedUtc, elapsedMs, reachable, skewMs (the naive half-RTT clock
estimate), probeKind (fleet-status, or health for a pre-mesh peer), and
consecutiveMisses. The metadata makes staleness judgeable by the caller:
watchIntervalMin is the effective probe interval and staleAfterMinutes is the
round-robin re-observation bound (interval × (N−1)).
Without the capability the route refuses:
With fleet.read it serves (an off-fleet daemon has an empty observation set — the
mesh degrades to self-description, never an error):
GET /v1/api/fleet/hosts/{town} serves one town's record, 404 when no observation
exists:
The third vantage¶
Peers watch from inside the fleet, the fleet-health Worker from Cloudflare's edge, the restoration monitor from an always-on box. Two vantages detect a fault; three localize it.
GET /v1/api/fleet/monitor serves what the peer-watch tick last pulled from the monitor's
bearer-gated document, plus vantageSplit — the towns this daemon's peer records and the monitor
disagree about, each with peerSaysReachable and monitorSaysReachable. A town only one watcher
has heard of is not a split.
Two fields exist because the cache can otherwise lie. stale — the cache is deliberately kept
when a fetch fails, so an hour-old document is byte-identical to a current one; the threshold is
fleet.monitor_stale_after_s (default 900). configured — a host never told where the monitor
is (fleet.monitor_url unset) must not read as a fleet with no faults.
The daemon pulls; the monitor never pushes. A push would hand a free shared LXC a write credential
into every host, invert git is the authority, and give the fleet a second actuator beside
/fleet/takeover.
Takeover — fleet.takeover¶
POST /v1/api/fleet/takeover, body { "reason": "<string>", "target": "<town>?",
"force": <bool>? }. The route validates and 202-enqueues; the daemon's tick
actuates (candidate ranking when no target is named, the open/merged takeover/* MR
pre-flight, the roster flip, the auto-merge MR — with the corroborating peer
observations embedded in the MR description). Observable behavior, in refusal order:
| Status | When |
|---|---|
503 actuation_unavailable |
the host holds no GitLab actuation credential — a fleet without the secret refuses loudly, never silently |
400 bad_request |
no reason |
409 takeover_cooldown |
a takeover was proposed or merged inside the cooldown window; the body carries the standing MR's mrIid/mrUrl (this response, to a capability-bearing caller, is the one place MR coordinates are returned) |
422 target_ineligible |
the named target fails the hard eligibility filter — force never skips eligibility |
422 no_candidate |
no eligible candidate exists; the body lists each rejection's reasons |
422 not_corroborated |
unforced, and this host's own peer records do not show the current primary dark for ≥ after_sweeps consecutive probes (3 by default), nor is this host the primary stepping down; force waives exactly this check and nothing else |
202 |
accepted: { "accepted": true, "target": "<town>", "queued": <bool> } |
Every 202 carries accepted and target. queued reports whether this call wrote
the job file. A false means the target town already holds a job at queued or
running, and the body then adds that job's status and requestedUtc. The tick
actuates that job's reason, force and corroborating observations, so a second call
revises nothing. Wait for the job to land, then read its outcome from the operational
stream. Once a job reaches done or failed, the next call for that town enqueues
again.
The docs daemon holds no actuation credential, so the loud-absence contract is the executed example:
Announce — fleet.announce¶
POST /v1/api/fleet/announce, body { "event": "role-flip", "newPrimary": "<town>",
"mrIid": <n>?, "epochUtc": "<iso>?" }. The verify-not-believe contract: the daemon
records the announcement and pulls its next sync tick forward — nothing else. No
role changes, no rendered state changes, regardless of the body's content; a bogus or
stale announcement produces the identical response and the identical (non-)effects.
Response 200 { "recorded": true, "syncScheduled": <bool> } — syncScheduled is
false on a daemon whose sync is disabled, which is itself the proof the announcement
carries no other power.
await fetch("https://torneg.nerthus.pl/v1/api/fleet/announce", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${peerToken}` },
body: JSON.stringify({ event: "role-flip", newPrimary: "eder", mrIid: 101, epochUtc: "2026-07-23T12:00:00Z" }),
}).then((r) => r.json());
Derived artifacts¶
GET /v1/api/derived lists what this host can hand over — one row per kind with the status
its own copy is in (fresh, stale, absent) and the archive stamp it describes.
GET /v1/api/derived/{kind} is the artifact itself, straight off disk, with
X-Nerthus-Derived-Stamp naming the archive so a caller can check before reading a body that
runs to megabytes.
Only a fresh artifact is served: one describing another archive answers 409 DerivedNotFresh
rather than travelling, because the asking host would refuse it on arrival. A kind outside the
closed list is 404 UnknownDerivedKind and names the list — a kind is a name in a table, never
a path fragment, which is what keeps this from being a cross-host file write.
Today's list is mentions: the postings behind GET /entities/{name}/mentions, minutes to
derive and a file to copy. The capability is derive.read, carried by the fleet peer token
beside fleet.read and fleet.announce — a bearer that may copy a derived cache is not thereby
one that may read lore or flip the apex. See the fleet's own
account of when a host asks.
See also¶
- Fleet — the mesh's design and the switch it serves
- Discord — the worker-dispatched close the same fleet serves
- Capabilities — the mesh capability ids