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
@pliktarget - 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
packagebuild 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 URL —
http://127.0.0.1:{port}/v1/api. The daemon binds127.0.0.1on an OS-assigned port (pin it with configserver.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);.owngrading 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-NerthusSession ⇒ POST /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, not404. Only the public routes (—capability) answer without a token. - WriteGate: every
Write-flagged route funnels throughAssert-NerthusWriteAllowed, which refuses with403in 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. ReadOnlyyields 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:SchemaTooOldandSchemaTooNewmean this host cannot parse the index correctly, so a preview computed over it is a wrong number printed as an answer, andSyncStalemeans 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 /routes⇒routes,GET /pu/history⇒assignments,GET /economy/timeline⇒points,GET /capabilities⇒capabilities,GET /entities/{name}/sessions⇒sessions,GET /sessions/narrator/{nick}/profile⇒sessions). 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 returns200with the result inline plus aJobId.?async=true(-AsJob) returns202+{ jobId, statusUrl, status }; pollGET /jobs/{id}(completed/failed) andGET /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: gzipis 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-Lengthdescribes 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-Encodingrides on every answer but the304. 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 JobRunningnaming 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 pulldoes — 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¶
- Architecture — the daemon/thin-client contract, discovery and auto-spawn, hooks, self-heal
- Permissions · Capabilities — tokens, the capability ACL,
.ownscoping, role bundles - Code style — the approved-verb gate · Configuration —
server.portand the tunable constants - The domain models behind the routes: sessions · PU · currency · locations · name resolution · the lore-file read surface