Skip to content

Discovery & health

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

Four public routes let a client learn about the daemon before it holds a token: whether it is up, the closed schema vocabulary it enforces, the format-version pointer, and the full route table. All four carry a null capability — no Authorization header, no auth. Every example below runs anonymously against a live daemon — see how the reference is tested.

Routes

Method Path Cmdlet Cap Write
GET /health Get-NerthusStatus (and Start-Nerthus after spawn)
GET /schema — (domain enums: types, statuses, tags, denominations, towns, roles, capabilities)
GET /routes — (the closed route table)
GET /schema/version Get-NerthusSchemaVersion

Cap is the required capability ( = public, no token); Write () marks routes that pass the write gate; a in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.

Health

GET /v1/api/health is the liveness probe. It returns status: "ok", the module version, the on-disk and expected schema versions, the current mode, uptime, request count, the loaded entity count, the process id, the imageDigest of the running container, a contract block naming what the host itself executes, and a sync block summarising the host's freshness.

The probe answers anonymously, and everything a fleet sweep compares rides that anonymous envelope: version, schema, image digest, fleet contract, and sync freshness. That is a deliberate trade — the daemon discloses its versions on a public hostname so no monitoring service ever needs a token for it.

imageDigest is the image reference the container was started with, empty on a host that runs the daemon outside a container. The sync block reports enabled, publishMode (push, mr, or none for a converge-only host), intervalMinutes, stale, staleAfterMinutes, lastConvergeOkUtc, and the lastAction of the most recent tick.

The contract block says what the host runs, as against the image above.

  • version — the fleet contract release the host's converge agent executes, read out of the infra checkout that agent converged rather than out of the pipeline that declared one. The two disagree when a host stops converging, which is the case the field exists to show.
  • sha — the infra submodule commit behind that release, whole rather than abbreviated, so a reader can check it against git submodule status on the box.
  • agent — sha256 of the converge agent script installed on the host. A separate fact from version: a device whose launch unit names a path outside git runs whatever sits at that path, whatever release its checkout holds.

Any of the three is null when the host did not say — a contract release older than Infra.Common 0.9.6, which renders none of them; a VERSION the agent could not read; a device with no hasher. null means unknown and never a version, and no value is ever substituted for one, so a fleet part-way through a rollout reads as part-way rather than as skewed.

Three fields describe how this host answers rather than what it holds:

  • readerThreads — how many threads it was told to give the reads the table marks poolable (server.reader_threads, 0 = every read on the accept loop).
  • readersLive — how many of them are still consuming the queue. A reader that dies leaves the loop serving everything itself, which is correct and otherwise invisible; the two numbers disagreeing is the only sign of it.
  • mentionIndex — which of four states a mention read would meet here: memory (this process built the postings), fresh (a file describes this archive), stale (one describes another), or absent. The last two mean GET /entities/{name}/mentions opens every transcript to answer, which is minutes rather than milliseconds, and the host is otherwise indistinguishable from a slow one. A host with server.mention_index_interval_min set — 15 minutes by default — rebuilds them in the background instead of waiting to be asked.

The memory block is what the process knows about itself and a cgroup counter cannot say.

  • retainedManagedMb — the managed heap, read without forcing a collection, so read gen2Collections beside it: a rising figure against a static collection count is allocation, against a rising one it is retention. A settled number takes two readings.
  • workingSetMb — resident size, so the resident/retained gap is a subtraction.
  • gcHeapLimitMb — the ceiling the runtime believes in. Under a memory cgroup this is 75% of the limit (1152 at a 1536 MiB cap); on an unconstrained host it is the machine's RAM.
  • gen2Collections — gen-2 collections since start.

A memory of null means reading it threw, which is not a supported state on any runtime the daemon ships on.

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

Response 200 (trimmed):

{ "status": "ok", "version": "0.1.0", "schema": "0.11.0", "expected": "0.11.0",
  "mode": "ReadWrite", "entities": 26, "pid": 40122,
  "imageDigest": "registry.gitlab.com/margonem/nerthus/it/nerthus.core@sha256:ab12…",
  "contract": { "version": "0.9.6", "sha": "c4943b79e40c8d09bd155dad67cb788c37c90ea8",
                "agent": "3f9c…" },
  "memory": { "retainedManagedMb": 121.2, "workingSetMb": 303.3,
              "gcHeapLimitMb": 1152.0, "gen2Collections": 2 },
  "sync": { "enabled": true, "publishMode": "none", "intervalMinutes": 5,
            "stale": false, "staleAfterMinutes": 60,
            "lastConvergeOkUtc": "2026-07-21T09:14:02.1180000Z", "lastAction": "converged" } }

Schema

GET /v1/api/schema returns the closed vocabulary the daemon validates against: the eight entityTypes, the statuses, the tag schema, denominations, towns, roles, and the derived capabilities.

The tag schema comes in three parts. tags is the flat list of every recognized key. universalTags names the keys legal on any entity, and tagsByType maps each entity type to the keys it adds on top — the same pair the tag schema renders as its Types column. A client reads them instead of hardcoding which key belongs to which type.

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

Response 200 (trimmed):

{ "entityTypes": ["NPC", "Grupa", "Lokacja", "Mapa", "Gracz", "Postać", "Przedmiot", "Region"],
  "statuses": ["Aktywny", "Nieaktywny", "Usunięty", "Niepewny"] }

Schema version

GET /v1/api/schema/version is the format-version pointer: the version stamped onDisk against the expected version this daemon build requires. A mismatch is what puts the daemon into read-only mode.

GET /v1/api/schema/version
await fetch("https://evocation.nerthus.pl/v1/api/schema/version").then((r) => r.json());

Response 200:

{ "onDisk": "0.11.0", "expected": "0.11.0" }

Routes

GET /v1/api/routes returns the entire closed route table — count plus one row per route with its method, path, required capability (null for a public route), the query names it accepts, four flags and one reason. This is the self-description a client reads to discover the API.

  • paramsthe closed set of query names this route accepts. An empty array is a claim that the route takes none, not an absence of information, and every row states one. This is the field that makes the API describable rather than discoverable by trial: a name outside the set is one the route does not read, so a filter spelled with it is a filter that was never applied.
  • write — the route passes the schema/write gate.
  • replica — its answer is a function of the committed tree alone, so any host holding that commit gives the same answer. That is what lets an edge route the read away from the apex, and what keeps /sessions off the list: its transcript states are a claim about one host. Carry X-Nerthus-Min-Commit when a read must not precede a write you just made.
  • pool — the daemon answers it on a reader thread rather than on its accept loop, so several can be in flight at once. Independent of replica: one says nothing about where the answer may come from, the other nothing about which thread produces it.
  • poolReasonwhy an unpooled route is unpooled, and null on a pooled one. pool: false alone does not say whether a route is on the accept loop because it is cheap or because it mutates, and those are opposite facts for anyone deciding what to cache, what to retry, or what a slow answer means. One of four:

    • mutates — something on its path writes to state the daemon shares. It is not on the pool for a correctness reason, and no amount of it being fast would change that.
    • memoizes — something on its path writes a cache file. Allowed on the pool; carried as its own value so it is not read as the one above.
    • shells — something on its path can start a git process. Not a write, but a subprocess on the thread that is serving.
    • default — none of the three. On the accept loop with no reason beyond the default.

    The values are derived from the daemon's own call graph rather than annotated by hand, and a guard in Nerthus.Core holds each declaration to what that graph can prove. - etag — the route mints a validator, so a caller may send If-None-Match and be answered 304. Without it a conditional request is a wasted header.

Two facts about the surface are derived from these fields, and are not written down as numbers anywhere. Which routes a replica answers is replica: true, and which are public is capability: null — both counted by whoever is asking, off the table their own host is serving. A count in prose is wrong the first time a route is added, and the fields cannot be.

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

Response 200 (trimmed):

{ "count": 249, "routes": [
  { "method": "GET", "path": "/v1/api/health", "capability": null, "write": false,
    "replica": false, "pool": false, "poolReason": "shells", "params": [], "etag": false },
  { "method": "GET", "path": "/v1/api/schema", "capability": null, "write": false,
    "replica": true, "pool": false, "poolReason": "default", "params": [], "etag": true }
] }