Skip to content

API reference

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

The daemon (nerthusd) exposes its entire functional surface over loopback HTTP as a closed route table — fixed at build time in daemon/routes.ps1. Every consumer (the Verb-Nerthus* cmdlets, the web dashboard, a future Discord/Margonem bot) reaches Nerthus through this one API; if a behavior is not a route here, no client can do it. Each cmdlet wraps exactly one route.

This reference documents main; a host serves the table in the image it was pinned to. Those are two different things whenever a route has landed since the pin, which is the normal state between rolls rather than an exceptional one. GET /routes is the authority for this host — it renders that host's own table, so it answers does the host I am calling have this route in a way no page here can. A route documented below and absent there is a host behind the reference, not a defect.

This page is the shared contract every endpoint obeys — base URL, auth, the cmdlet mapping, the middleware chain, envelopes, status codes, jobs, and the event stream. The per-domain pages below carry the route tables and a worked, executed example per route.

The host in every example is production, and production does not resolve yet

The worked examples on these pages call evocation.nerthus.pl, which is the future production apex. It is NXDOMAIN — measured 2026-08-29 against 1.1.1.1, and it has been for as long as anybody has looked, because production was never stood up. Paste one of these URLs into a terminal today and you get a DNS failure, not a 401.

The same is true of every per-town production hostname the reference uses — torneg.nerthus.pl, werbin.nerthus.pl. Those are real town slugs from the fleet's own allowlist; the hosts that answer for them today carry a dev- prefix.

What answers today, if you want to run an example rather than read it:

Instead of Use
evocation.nerthus.pl dev-evocation.nerthus.pl
torneg.nerthus.pl dev-torneg.nerthus.pl
any other <town>.nerthus.pl dev-<town>.nerthus.pl, for a town in the roster

The examples are deliberately not rewritten to the dev names (operator ruling, 2026-08-29). Rewriting 211 addresses to dev would mean rewriting them back at cutover, by hand, and would leave the reference describing a testbed in the meantime. Project 98 owns whether the production names come to resolve; the day they do, every example on these pages starts working and this box is what gets deleted.

The pages

Each page owns one slice of the route table, in the Tussal/Eraster world:

  • Discovery & health/health, /schema, /routes, /schema/version
  • Entities — the eight entity types: read, search, create, edit, retire, rename
  • Name resolution/resolve, the name index
  • Players & characters/people, /characters, Discord channels, character info
  • Sessions — search, integrity, the participation graph, the session workflows
  • PU & elections — the monthly award batch, timelines, eligibility
  • Currency & economy — holdings, denominations, snapshots, timelines
  • Locations & maps — the location hierarchy, the map catalogue, traversal
  • The world grid — a Mapa's coordinates and dimensions as a rectangle on Margonem's grid
  • Regions — named, time-scoped groupings of locations, membership as of a date
  • Lore files — the lore tree as bytes: the file listing, a file or one of its sections, an entity's @plik target
  • The editing plane — an editing session as a named set of pending lore changes, published as one act
  • Logs & audit — log fetch/parse, the audit streams
  • Search — one phrase across the lore, the sessions and the speech layer
  • Discord — delivery, and the worker-dispatched close ingest
  • Fleet mesh — the status card, peer observations, takeover and announce
  • GitLab CI proxy — the pipeline topology, the cascade, a job trace, and the two acts
  • Governance — narrator permissions
  • The governance ledger — who held which function when, and which rule was in force on a day, as data with the post behind each state change
  • Narrator signals — the two sources that answer who narrated, and where they disagree
  • Auth, identity & tokens — sign-in, capabilities, token lifecycle
  • Keyrings & key delivery — named recipient sets, published epochs, the one-time claim
  • The recovery document — the map, its watermark, and the issuance tripwire
  • Chat moderation — the record ledger, evidence, export and the transparency chain
  • The regulation corpus — the documents, what was in force on a day, the tariff
  • The moderator roster — who holds mc/smc, since when, and who can read a record
  • The model lifecycle — which trained artifact the proposer reads, promoting, rolling back, and the record of who did which
  • Labelled samples — the labelled data the normalization programme deposits, and the four-bucket split census
  • Finetuning samples — the second sample store: chat exchanges for training, the ruling path, and the export with the stamp that makes an adapter reproducible
  • Settlement — the monthly settle run and the map checkup
  • Query parameters — every query name the route table declares, per route, and which are proven by an example
  • Events and jobs — the SSE stream and its journal, and the two-route job envelope
  • Admin & lifecycle — mode, import, sync, jobs, events, shutdown

Every example on these pages is executed

The request in each HTTP tab is the exact request CI sends against a real daemon on the canonical Tussal/Eraster fixture; what the example's directive asserts is asserted. The runner is tests/DocExamples.Tests.ps1 in the nerthus.core repo, whose CI clones this repo to read these pages — a wrong example turns the module pipeline red, so the reference cannot drift from the code: the reference is the test corpus.

The Response block under an example is not asserted, and the directive above it is

Get-DocApiExamples reads the <!-- api:example --> directive and the first ```http fence after it, and everything past that fence is prose to the runner. So the Response NNN illustration — the block you are most likely to copy — renders and is never executed. That is how this reference published "count": 156 on the /routes page against a live 231 for a month, with the example above it passing: its directive asserted presence and a path regex, and count was already in the vocabulary.

Every numeric literal in an illustration is now either asserted by one of that page's directives or carries a row in ci/illustrations-declared.txt in this repository naming its class and the reason. The docs-illustrations job holds the two to each other in both directions. Read a number in a Response block as an illustration unless the directive above it names it.

When the replay actually runs

Stated because it is an accepted risk rather than an unnoticed one, and an accepted risk nobody wrote down is indistinguishable from the other kind.

The replay fires when nerthus.core next builds, and at no other time. Neither project carries a pipeline schedule — pipeline_schedules answered [] for both on 2026-08-22 and again on 2026-08-28 — and nothing in this repository's pipeline triggers Core's. So a merge request that changes a number on one of these pages is checked here for dead links, for the per-page census, and for unasserted illustration literals; it is replayed against a daemon only when somebody next pushes to nerthus.core.

That is a deliberate choice, taken 2026-08-28 with its two alternatives measured:

  • A downstream trigger from this repository into Core was refused. The estate runs on 400 CI-minutes a month, and a per-merge-request daemon boot is a recurring cost on a budget that already shapes the fleet's health sweep.
  • An offline contract snapshot — Core's package build publishing the route table and the capability list as an artefact, asserted here with no daemon at all — is the durable answer and is not built yet. It waits on project 90, which emits the machine-readable contract from the same route table; building a second generator beside it is the failure this whole effort is named after.

Until 90 lands, the pairing is the control: the pester job clones this repository at $CI_COMMIT_REF_NAME before falling back to the default branch, so a nerthus.docs branch and a nerthus.core branch of the same name are tested together, and that is how a reference change is proven before either merges. And the count is held page by page, not in total. tests/doc-examples-declared.psd1 beside that runner carries one row per page in this directory — including the pages that carry no example, because a page at zero is the one whose loss no total can notice — with its exact example count and a Total over all of them. Adding or removing an example, or a page, is a paired one-line edit there; the suite names the page and prints the row to paste. It replaced a floor on the total whose own comment recorded what it could not see: a floor on the total cannot see WHICH page vanished.

The JavaScript tab is a projection of the same request, shown for browser consumers, not independently executed. Names in paths are URL-encoded; the examples run with a full-capability token unless a section shows a scoped one.

Base URL and auth

  • Base URLhttp://127.0.0.1:{port}/v1/api. The daemon binds 127.0.0.1 on an OS-assigned port (pin it with config server.port), written to .nerthus/runtime/daemon.port; discovery and auto-spawn are in Architecture.
  • Versioning — the version lives in the path (/v1/api/...); a breaking change would add /v2/api/... alongside.
  • Bearer auth — every request carries Authorization: Bearer {token}. Token kinds (machine, session, named, api) and minting are owned by Permissions.
  • Capability — each route declares one required capability (entity.read, pu.award, player.write.own); .own grading and the role bundles are owned by Permissions, the id list by Capabilities.

The cmdlet mapping

Every Verb-Nerthus* cmdlet does the same three things: discover the daemon (walk up to the nearest .nerthus/ marker, read runtime/daemon.port + daemon.token, auto-spawn and wait up to 60 s for GET /health), make one HTTP call to the single route it wraps, and project the JSON into [PSCustomObject]s (collection cmdlets emit one object per item). A non-2xx throws one generic terminating error carrying the status and body; on a 401 the client re-reads the possibly-rotated token once and retries. A convenience that needs two domain calls is a workflow route surfaced as one cmdlet (Close-NerthusSessionPOST /workflows/close-session). Verbs come from the approved set (Code style); Find-* (search) is kept distinct from Get-* (fetch by key).

-WhatIf?dryRun. Mutating cmdlets declare SupportsShouldProcess; -WhatIf appends ?dryRun=true, so the daemon validates and reports the would-be change without touching disk. A handful short-circuit locally instead (no HTTP call): Stop-Nerthus, Set-NerthusMode, Set-NerthusSessionHash, Set-NerthusSessionGraph, Invoke-NerthusSessionDistribution, Invoke-NerthusSessionLogFetch, Invoke-NerthusReindex, Set-NerthusNarratorPermission, and Get-NerthusSessionLog -Refresh/-FetchMissing. Three passthrough parameters:

Parameter Effect
-Daemon <uri> Skip discovery; talk to an explicit daemon base URL.
-Token <string> Override the discovered token (e.g. an identity-bound session token).
-AsJob On Invoke-NerthusPUAssignment only: send ?async=true, return the 202 job handle.

The middleware chain

Every request flows through one fixed pipeline; the route table is consulted in the middle and never extended at runtime:

Authenticate (401) → RouteMatch (404) → CapabilityCheck (403)
  → WriteGate (403) → ContentType (415) → body parse (400)
  → BeforeWrite (422) → dispatch
  • Authenticate precedes RouteMatch: an anonymous probe of an unknown path is 401, not 404. Only the public routes ( capability) answer without a token.
  • WriteGate: every Write-flagged route funnels through Assert-NerthusWriteAllowed, which refuses with 403 in read-only mode (ReadOnly), on a not-yet-adopted repo (SchemaTooOld), on index-format drift (SchemaTooNew), or on a stale sync (SyncStale). The Write flag is per route, never derived from the method — a read-only POST (e.g. POST /resolve) is never gated.
  • ReadOnly yields to ?dryRun=true, and it is the only arm that does. A replica may not write and can still compute, so a preview is answerable there — and a console pointed at a replica is the normal case, not an exception. The other three arms refuse a dry run exactly as they refuse a write: SchemaTooOld and SchemaTooNew mean this host cannot parse the index correctly, so a preview computed over it is a wrong number printed as an answer, and SyncStale means the tree the preview would be computed against is not the tree the write would land in.
  • BeforeWrite fires after the gate; a throw rejects the write 422 WriteRejected. Closed-schema validation (unknown @typ/tag) lives in the handlers.
  • dispatch also fires the After* hooks (audit, SSE, Discord); a throw there is a warning — the write already stands. The hook list is owned by Architecture.

The Write flag marks routes that mutate lore/index files, plus durable governance/identity state and the log-fetch archive writes. Deliberately unflagged: the control-plane recovery routes (POST /import, POST /admin/mode, POST /admin/shutdown, POST /sync) that must stay operable to recover from a lock; POST /discord/send (writes only delivery state); and the in-memory rebuilds (POST /name-index/rebuild, POST /sessions/graph).

Envelopes

Mutating bodies are JSON. Responses are always JSON, in one of three shapes:

  • Single object — one entity, session, or report, returned directly.
  • List envelope{ "count": N, "items": [ … ] }, with three more keys when the request asked for a window (see Narrowing a list read). A few routes name their collection after the domain (GET /routesroutes, GET /pu/historyassignments, GET /economy/timelinepoints, GET /capabilitiescapabilities, GET /entities/{name}/sessionssessions, GET /sessions/narrator/{nick}/profilesessions). The wrapping cmdlet unwraps whichever the route uses.
  • Job envelope — the monthly PU batch (POST /workflows/award-pu) runs as an in-daemon job. It executes synchronously; by default the route returns 200 with the result inline plus a JobId. ?async=true (-AsJob) returns 202 + { jobId, statusUrl, status }; poll GET /jobs/{id} (completed/failed) and GET /jobs/{id}/result.

Narrowing a list read

Two query parameters shrink a list answer, and they compose. Both are hints about payload size rather than writes, so a value the daemon cannot parse falls back instead of refusing.

?limit= and ?offset= window the rows. GET /entities, GET /sessions, GET /sessions/summary and GET /normalization/suggestions take them. Naming either key adds total, limit and offset beside count: total is what the window was cut from, while count stays the number of rows returned. Naming neither answers whole, so a consumer written against the two-key envelope reads the body it always did. A ?limit= that does not parse falls back to the whole set, and a negative ?offset= reads as zero.

?fields= narrows the rows themselves, on the two session listings — GET /sessions and GET /sessions/summary. Names are comma-separated and matched without regard to case. Header survives whether it is named or not, because every row in this system is addressed by it. An unrecognized name is dropped rather than refused, so ?fields= naming nothing the row carries answers with headers alone, and ?fields= naming every field is the whole answer, only slower.

The window applies before the field cut, so total counts rows either way.

Request Body on the campaign corpus
GET /v1/api/sessions 6.40 MB
GET /v1/api/sessions/summary 2.00 MB
GET /v1/api/sessions?fields=Date,Title,Narrator,Participants,LogCount 470 kB

The middle row drops Description and LogStates; the last keeps the five fields a finder renders. Neither narrowing changes which sessions answer, only what each row carries.

Compression

The daemon gzips a response body when the caller offers it. Accept-Encoding has to match gzip, the body has to be at least 1024 bytes, and the status has to be something other than 304 — a revalidation has no representation to compress. Below the floor a gzip member's own header and trailer are a measurable share of the payload, and every GET /health answer is smaller than that.

Three consequences for a client:

  • Content-Encoding: gzip is a result, not a promise. The daemon compresses, compares, and keeps the raw bytes when the packed form came out larger, which happens on short high-entropy answers. A compression failure falls back the same way rather than failing the request. Read the header; do not infer it from having asked.
  • Content-Length describes the bytes on the socket. It is the compressed length wherever the header is set, so a client sizing a progress bar off it is measuring the transfer rather than the JSON.
  • Vary: Accept-Encoding rides on every answer but the 304. An intermediary holding one encoding must not hand it to a caller that asked for the other.

The narrowed listing in the table above leaves the daemon as 100 kB, and the whole listing as 2.22 MB. The Verb-Nerthus* cmdlets ask for gzip and never see an encoded body: the shared HttpClient handler decompresses gzip and deflate for them. GET /events is intercepted ahead of dispatch and streams uncompressed.

Background jobs

The corpus-wide sweeps over nerthus.logs/ are the one kind that does not run on the serve thread. A cold mass fetch is hundreds of transcripts each carrying its own network timeout, and the daemon answers one request at a time, so run inline it would answer nothing else for minutes — reads behind it do not queue, they time out.

POST /logs/fetch with no target, and POST /logs/archive/reparse or POST /logs/index/rebuild with ?async=true, hand the work to a worker thread and answer 202 with { job, kind, status, statusUrl }. GET /jobs/{id} reports running, completed or failed, and a completed record carries the sweep's summary under result.

  • One at a time. A second while one is out answers 409 JobRunning naming the running job. Two sweeps would write the same archive from two threads.
  • No shared state. The worker builds its own model over the same repo. What it writes reaches the serve thread the way a hand edit or a git pull does — through the model fingerprint and the archive digest each read already checks. There is nothing to invalidate by hand and no restart.
  • Reads keep being served. That is the point of the shape.
  • No cancel. A sweep runs to its end or fails.

Progress still reaches /events (see Progress ticks), now published from the worker.

The read generation

The answers every read shares — the session records, the serialized campaign, the participation cast map, the log-to-sessions map — are pure functions of the lore model and the transcript archive. None of them depends on who is asking, so they are built once per generation on the thread that owns the model, before dispatch, and a read looks one up rather than deriving it.

Two consequences worth knowing:

  • A read mutates no daemon state. That is what makes it safe for more than one thread to be in one at a time, and it is the prerequisite for serving reads concurrently.
  • The first request after an edit does not pay for everyone else. Lazily-built caches put the whole bill on whichever request happened to arrive first.

The two inputs move independently and each cache is stamped with the one it derives from: a fetch rewrites the archive while every session block stays byte-identical, and rebuilding the cast map for that would walk the corpus for nothing. Whether the archive moved is a two-syscall check — every sidecar reaches it through a rename, which moves the directory's own timestamp — and only a directory that moved is walked file by file.

Reader threads

server.reader_threads puts the reads the route table marks poolable on their own threads instead of the accept loop. Zero, the default, is the single-threaded daemon: nothing about the pool is on unless a deployment asks for it.

The flag is per route and opt-in. It asserts that the route and everything it calls only reads the published generation. Eighteen reads carry it: the nine poolable /entities reads, the session listing and its prose-free summary, one session record and its transcripts, the participation graph, a narrator's profile, the two lore-file reads, and an archived transcript. GET /routes publishes pool per row, so a host reads the current set off the daemon rather than off a count here.

The speech reads over the Lokalny channel answer on the accept loop instead, GET /entities/{name}/relations among them. Add-NerthusSpeechSubjectMap bolts its lookup maps onto the index object every reader is holding, and a reader writing to shared state is what the flag exists to exclude. Writes are never pooled.

A shutdown settles what it handed over: the queue closes, readers get a bounded grace to finish what they hold, and anything still queued is answered 503 ShuttingDown rather than abandoned — an abandoned handoff reaches the caller as 200 with an empty body, which is the worst way for a read to fail because it looks like an answer. The accept loop also stops handing work to a pool whose readers have died, and falls back to answering inline.

Each reader is its own runspace and loads the daemon's source list for itself, the way a background sweep does. It also builds its own route table, because a scriptblock belongs to the runspace that created it. What crosses between threads is one reference per request: the generation, finished before it is handed over and never written to afterwards.

Raise server.selfheal_interval_ms alongside it. The self-heal fingerprint stats every session-bearing file — a thousand of them on the production corpus, measured at 10 ms — and at zero it runs ahead of every request, which caps the loop no matter how many readers are behind it. Set to 250, an edit made behind the daemon's back heals within a quarter second instead of within one request, and the two knobs together are what the pool is worth:

3282 sessions, 8 clients requests/s p50 inconsistent answers
single-threaded 70 39 ms 0
4 readers 87 8 ms 0
single-threaded, lore edited under load 5.4 45 ms 0
4 readers, lore edited under load 8.2 8 ms 0

Measured on the daemon's own clock, a request answered by a reader has a median of 1 ms. The consistency column is the one that matters: across roughly two thousand requests spanning thirty-six model rebuilds under live load, no answer ever mixed two generations.

The participation graph moved into the generation with the rest, so GET /sessions/graph is a lookup rather than a walk of every session resolving every name it holds. That walk was seconds per request; it is now paid once per model, at boot and after each edit.

What the pool does not fix is a rebuild itself. A lore edit costs a model rebuild and a generation rebuild — measured at 3.4 s and 1.4 s on that corpus — and new connections wait on the accept loop while it runs; the accept loop's own p95 under continuous editing is 4.9 s against the readers' 16 ms. Under that load the daemon is rebuild-bound, not read-bound, and more readers do not change it.

Answering from another host

A read the closed route table marks Replica answers from the committed tree alone, so any host holding that commit gives the same answer. GET /routes publishes the flag beside write, which is how an edge reads its routing table from the daemon instead of carrying its own.

It is a different question from Pool. Pooling asks whether a route mutates shared state; replica-safety asks whether it reads state that is local to one host. .nerthus/state/, .nerthus/staging/, runtime/ and log/ are all local, and so are the two facts inside cache/ that no commit re-derives: the .failed retry markers, which record what this host tried to fetch, and session-hashes.json, which is its integrity baseline. That is why the session reads reporting transcript states carry no flag — a state of Failed is a claim about one host — while GET /sessions/summary, which leaves those states out, does.

Read-your-writes. A 2xx carries X-Nerthus-Commit, the commit the answer came from. A caller that has just written sends it back as X-Nerthus-Min-Commit, and a host that cannot show it holds that commit answers 409 CommitBehind with required and serving, so an edge falls through to the primary rather than serving lore the caller has already watched move. Without the header the check costs one string comparison and changes nothing.

The entity tag is keyed to the commit wherever a host can name one, so two hosts serving the same lore mint the same tag and a revalidation survives being routed elsewhere. A dirty worktree, an unadopted repo, or a checkout that is not a git repository falls back to a local fingerprint, which never claims to travel.

Revalidation

A read that declares an ETag honours If-None-Match, answering 304 with no body when the copy a caller holds is still current. Which routes those are is published per row: etag on GET /routes. A count here would be the second copy of that field and the one that goes stale.

The tag is computed before the handler runs, from something cheap — the model fingerprint already in hand, one dictionary hit, one stat — so a revalidated read costs the daemon the comparison rather than the answer. Cache-Control: private, no-cache rides along: a lore read is per-identity, so a shared cache must not keep it and a browser must ask rather than guess from its own clock.

A tag is a function of the answer and of the request, and of nothing else. The request half is bounded by the route's declared params: a query name the route does not accept changes nothing about the body, so folding it into the tag would mint an unbounded number of validators for one answer — a caller varying a junk parameter would revalidate nothing, and a cache in front of the daemon would store one body under every name anybody invented. Measured on this tree: twenty junk names on one route minted twenty distinct tags before the bound and one after it.

The public surface revalidates too. Every anonymous read that is a function of the tree carries a tag; the ones that do not are the liveness probes, the credential mints and the one-time claim reads, where a validator would be a lie about a body that changes on every request or a cache key over a secret.

Route What moves the tag
GET /entities, /entities/integrity, /entities/search any lore edit, or the projection epoch advancing
GET /entities/{name} and its /player, /locations, /sessions, /path, /file reads any lore edit, or the projection epoch advancing
GET /sessions/summary, GET /sessions/graph any lore edit, or the projection epoch advancing
GET /sessions any lore edit, or any transcript reaching the archive
GET /sessions/{header} that session's content hash, or the archive
GET /sessions/{header}/logs that session's content hash, or the archive
GET /normalization/suggestions any lore edit, or the archive
GET /files any lore edit
GET /files/content that file's length and last-write time
GET /logs/archive/{log} that sidecar's last-write time and length

The archive counts alongside the lore because a fetch changes a session's transcript states while every session block stays byte-identical. The two /files routes are specified on Lore files.

The projection epoch. A temporal field opens and closes on the clock rather than on the tree: @region membership or a dated @status can lapse while every byte stands still. The reads over the model projections therefore name the projection set they answered from — a counter the daemon bumps each time it resolves those projections afresh — so two answers either side of a range closing get different tags out of one unchanged commit.

Every tag also names the request it answers: the route pattern, the path parameters, and the query. A conditional request is never satisfied by the tag from a different question, and two spellings of one request share a tag.

A route that cannot name an identity cheaply declares none, and the read runs as it always did. The opt-out is per request as well as per route. A read answering out of the staged overlay drops its tag, because a draft is not in any identity the lore model can offer. So do GET /files/content with a ?path= that resolves to nothing, and GET /logs/archive/{log} with no sidecar behind the name.

The ambiguity headers. A read that resolves a name several entities claim answers 200 with the tie-break winner and says so out of band: X-Nerthus-Ambiguous: true plus X-Nerthus-Candidates, the contesting names joined by ;. The body keeps its shape, so a caller who does not care is unaffected. Candidate names are percent-encoded — a header value is US-ASCII and the lore is Polish, so Ratusz Ithan - więzienie travels as Ratusz Ithan - wi%C4%99zienie and decodes with any URI unescape. Only what a header cannot carry is escaped, % included; a name that is already ASCII passes through unchanged. Write paths do not tie-break at all; they refuse with 409 AmbiguousName.

Query values are form-encoded. A space travels either as %20 or as +, and a literal plus as %2B — the ordinary query-string convention, and what URLSearchParams, an HTML form and curl -G --data-urlencode all produce. This matters most for the values that are routinely multi-word: ?session= takes a whole session header, ?text= takes a phrase.

Every mutating route accepts ?dryRun: validation runs and the response describes the would-be change without touching disk. Two exceptions still run their network step under a dry run — POST /sync (refreshing origin refs is the point; it skips commit/push and reports wouldPublish) and POST /sessions/graph (an in-memory rebuild).

The value is parsed, and a value that will not parse is refused. true, 1, yes and on all ask for a preview; false, 0, no and off all ask for the write; case does not matter. Anything else — and a bare ?dryRun with no value at all — answers 400 DryRunInvalid naming the accepted set. A dry run is a request not to write, so an argument the daemon cannot read is not one it can honour: answering 200 after writing is the one reading a caller cannot detect and cannot undo.

That sentence is now load-bearing rather than descriptive: because the write gate's ReadOnly arm yields to a dry run, a Write-flagged route whose handler ignored the flag would perform its write on a replica. tests/DryRunGate.Guard.Tests.ps1 holds every Write row to it, following handler delegation, with each exception named and reasoned. POST /sessions/{header}/logs/refresh was the one route the claim was untrue of: it pulled transcripts into the committed archive under ?dryRun=true, and now names the URLs it would fetch instead.

Status codes

Code Meaning
200 Success (including every dry run).
201 Resource created (New-Nerthus*, an added character-info entry).
202 Job accepted (?async=true); poll statusUrl.
304 The If-None-Match a caller sent still matches the route's tag; no body.
400 Malformed JSON body, a missing required parameter, or a query value the daemon cannot parse (DryRunInvalid).
401 Missing or invalid Bearer token (also anonymous probes of unknown paths).
403 Capability denied, an own-scoped caller targeting a foreign subject, an own-scoped caller with no Gracz to scope to (NoOwnScope), or the write gate refusing (ReadOnly / SchemaTooOld / SchemaTooNew / SyncStale).
404 Route not in this host's closed table (RouteNotFound), or resource not found (EntityNotFound for a name the index cannot place). On a route documented here, check GET /routes before reporting a defect: the host may predate the route.
409 Conflict — an ambiguous Margonem id, a settle op the ledger echo already lists (PULedgerDrift / TransferLedgerDrift), a stale character-info match, an already-taken (name, type), a Gracz @discord channel overridden by an overflow block, or a POST /sync a daemon cannot run (SyncDisabled / NotAGitRepo / GitMissing / NoSyncBranch).
415 Missing/wrong Content-Type on a mutating call.
422 Body parsed but failed domain validation — unknown @typ/tag, PUUnresolvedCharacters, PermissionFieldRejected, a BeforeWrite rejection.
500 Unhandled handler error (UnhandledError). The exception message is written to the host's operational stream and is not on the wire: it is a sentence about the daemon's insides, and this arm answers anonymous callers.
503 The daemon cannot do the work now — a read the shutdown drained (ShuttingDown), a Discord kind the write gate refuses (daemon_write_gated), a report the repository state blocks (report_publish_unavailable), or a fleet route whose credential this host does not hold (actuation_unavailable).

Error bodies are contract. Every error carries { "error": "<id>", … }; the id string is stable and tested, and the sentence, where there is one, travels in detail. There is no second sentence field: detail is the only one, and a body's other members are data a client may read — required on a Forbidden, allowed on a SearchDiegesisInvalid, reason where a refusal carries a sub-id. No arm is exempt, the unhandled-error arm included; an id a client cannot branch on is the thing this contract exists to prevent.

The two 404s are different ids on purpose, because a caller must be able to tell them apart and cannot tell them apart from the status. RouteNotFound is a fact about the endpoint — this host's table has no such route, and a client that learns it once can stop asking. EntityNotFound is a fact about one name, and a client that reads it must keep asking the same route with other names. Read the first as the second and a feature silently switches itself off for a whole session; read the second as the first and the endpoint is downgraded for the life of the tab over one misspelled name.

ReadOnly fires on a host that boots read-only — a replica by role, or one flipped with POST /admin/mode — and the remedy is to address the primary instead. SchemaTooOld fires on an un-adopted repo, where the remedy is Initialize-NerthusRepo (POST /import); the two are separate ids because the remedies are different and monitoring must tell an intentional replica from a broken repo. SchemaTooNew fires when a clone pulled an index-format bump its module does not understand yet. SyncStale carries the remedy ladder — the rule is owned by Sync; PUUnresolvedCharacters returns its grouped unresolved[] body (PU model).

Structured refusals

A service that must name the thing it refused throws a structured refusal, and the dispatcher returns that envelope verbatim. The id, the status and the named fields are all contract, exactly like the gate ids above.

Id Status Carries Raised when Remedy
AmbiguousName 409 name, candidates[] A -Strict write path resolves a name that several same-priority entities of one type claim — session fan-out, the transfer endpoints, the close gate. Use the qualified form, or pass the containing city. See Name resolution and Sessions.
EntityNameTaken 409 name An auto-create would mint a second ### block under a name already in the index; the second would be shadowed or merged. Credit the existing entity, or pick a distinct name.
ChokeFileRefused 409 file, operation A write targets a file the settlement robot rewrites wholesale. Route the write at the entity's own @plik.
SessionHeaderChanged 409 header A close finds the session's header no longer matching the one it started against. Re-read the session and retry against the current header.
SessionRegistryTreeTarget 409 path A write targets the generated Lokacje/ registry, which is output only. Point @plik outside the generated tree.
SessionRegistryGenerationIncomplete 409 reason The session registry could not be regenerated in full, so a dependent write would land against a partial tree. Re-run POST /session-registry/regenerate and read the reported reason.
PermissionFieldRejected 422 reserved[], unknown[], allowed[] A Uprawnienia edit carries a field outside the row's closed column set, or one of the identity members. Send only the documented columns; identity is the path segment (Governance).
CapabilityRequired 403 capability A request needs a capability the principal's set does not satisfy, named explicitly rather than as a bare denial. Grant the named capability, or use the narrower route.
RefTargetProperty · RosterStoreProperty · ReportRejected · WriteRejected 422 varies Documented with their own subsystems. See Entities, Players, Sessions.

AmbiguousName is the one a narrator meets most: it is what a close refuses with when the session names a homonymous place, and candidates[] is the list to pick from.

Server-Sent Events

GET /v1/api/events (capability events.subscribe) keeps an open text/event-stream, handed to a dedicated pump thread so it never stalls ordinary requests. Frames are named events with sequence ids (id: 42 / event: pu:awarded / data: {…}), a keepalive comment every 30 s, and ?since=<seq> replays buffered events after <seq> (?since=0 = the whole buffer). Auth is in the Authorization header, never the URL — a browser consumes the stream with fetch + ReadableStream, not EventSource.

Event Fires when
entity:write An entity is created/updated/soft-deleted.
session:staged A draft was accumulated or edited — data carries the header, the draft id, and the action.
staging:overwritten Another host overwrote this host's draft ref — data names both towns.
session:landed A staged session's close was pushed to the serving branch and finalized.
session:escalated A close could not land on the serving branch, so it opened a sesja/<stamp> merge request.
session:reported A self-service report was pushed as a zgloszenia/<stamp> merge request.
session:distributed A session was distributed to its participants' files.
pu:awarded A monthly PU batch committed.
transfer:applied A session's @Transfer directives were applied to holdings.
governance:changed A narrator's Uprawnienia row was edited.
sync:completed A sync tick finished — data carries the action, outcome, and SHAs (Sync).
converge:lint A post-converge lint finished — data carries the sha, the finding counts, and the kinds found (Sync).
mapcheckup:progress A map checkup advanced — see Progress ticks below.
logfetch:progress A log fetch advanced — see Progress ticks below.

session:escalated and session:reported both mean a branch is now waiting on the Rada rather than a write having landed, and data names the branch in each. staging:overwritten is the one that needs a human: drafts are never merged, the last writer won, and the session has to resume on one host.

The journal: the same events, by cursor

The stream serves whoever is connected. GET /v1/api/events/journal serves whoever was not: the durable events (never the progress ticks) are appended to .nerthus/log/events.jsonl, and ?since=<seq>&limit=<1-1000> walks forward from where a reader stopped. Same capability as the stream — a caller allowed to watch this host's events may read the ones it missed.

{ "enabled": true, "since": 1836, "seq": 1842, "truncated": false, "oldestSeq": 1204,
  "count": 6, "nextSince": 1842, "items": [ { "ts": "…", "seq": 1837, "type": "sync:completed",
  "town": "werbin", "data": { "action": "fast-forward" } } ] }

seq is what this host has published in total, so a reader knows how far behind it still is. truncated is the honest answer to a cursor older than anything still in reach: the lines between are gone, oldestSeq is where to re-anchor, and a gap gets reported rather than read as quiet. server.event_journal turns the file off (Configuration); the stream is unaffected either way.

A sequence counts what one host published, so the route is deliberately off the replica flag — every host serves its own journal, and an edge free to pick a host would answer one host's cursor with another's events. A fleet-wide reader polls each hostname and keeps a cursor per town.

Progress ticks

A sweep that holds the serve thread cannot be asked how far along it is, and a sweep on a worker thread has no request to answer with. These two events are how one says so unprompted: data is { done, total, detail }, where detail names the map or URL just finished, and total counts the slice this call walks — not the whole catalogue, which for a batched sweep would describe someone else's work.

Ticks are throttled to at most 100 per run, and are the one event kind that is not buffered for ?since= replay: they are meaningless once the run has finished, and a sweep emits enough of them to push every durable event out of the bounded window if they were kept.

Ordering is the contract. Subscribe before starting the sweep, or the ticks already emitted are gone — they are transient, so ?since= will not replay them. -ShowProgress on Invoke-NerthusMapCheckup and Invoke-NerthusSessionLogFetch does exactly this and renders a done/total bar; a caller without events.subscribe still runs the sweep, just without the bar.

Event Fires when
job:finished A background sweep ended — data carries { id, kind, status }.

See also