Skip to content

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.

GET /v1/api/fleet/status
await fetch("https://torneg.nerthus.pl/v1/api/fleet/status").then((r) => r.json());

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:

GET /v1/api/fleet/peers
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/fleet/peers", {
  headers: { "Authorization": `Bearer ${scopedToken}` },
}).then((r) => r.json());

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/peers
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/fleet/peers", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

GET /v1/api/fleet/hosts/{town} serves one town's record, 404 when no observation exists:

GET /v1/api/fleet/hosts/nieznane
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/fleet/hosts/nieznane", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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.

GET /v1/api/fleet/monitor
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/fleet/monitor", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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:

POST /v1/api/fleet/takeover
Content-Type: application/json
Authorization: Bearer <token>

{ "reason": "primary dark w peer watch" }
await fetch("https://werbin.nerthus.pl/v1/api/fleet/takeover", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ reason: "primary dark w peer watch" }),
}).then((r) => r.json());

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.

POST /v1/api/fleet/announce
Content-Type: application/json
Authorization: Bearer <token>

{ "event": "role-flip", "newPrimary": "eder", "mrIid": 101, "epochUtc": "2026-07-23T12:00:00Z" }
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