Skip to content

Logs & audit

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

Transcripts and the audit trail. The daemon archives Margonem session logs under nerthus.logs/, parses transcript text into structured lines, and keeps three rolling operational streams (operational, request, audit) plus two derived audit views — the transaction ledger and the notification log. This page covers fetching, parsing, refreshing a session's logs, reading a stream, and reading the audit views.

Reads need log.read; the transaction ledger needs currency.read; fetching and refreshing need log.fetch and are Write routes (they write the committed archive, so they pass the schema/write gate). Issuing a raw HTTP request through the fetch pipeline costs a second capability, log.fetch.request. The examples stay offline: the fixture session 2026-07-01, Eraster rozmawia z Tussalem, Anward carries no @Logi, so nothing reaches the network. Every example runs against a live daemon — see how the reference is tested.

Routes

Method Path Cmdlet Cap Write
POST /logs/fetch Invoke-NerthusSessionLogFetch log.fetch
POST /logs/import log.fetch
POST /logs/parse Get-NerthusSessionLogParsed log.read
POST /sessions/{header}/logs/refresh Get-NerthusSessionLog -Refresh / -FetchMissing log.fetch
GET /logs/status Get-NerthusSessionLogStatus log.read
GET /logs/lines Find-NerthusLogLine log.read
GET /logs/speakers Get-NerthusLogSpeaker log.read
GET /logs/archive/{log} Get-NerthusArchivedLog log.read
GET /logs/archive/{log}/mentions log.read
GET /logs/archive/{log}/runs log.read
POST /logs/archive/reparse admin.index
POST /logs/index/rebuild admin.index
GET /entities/{name}/lines Get-NerthusEntityLine log.read
GET /entities/{name}/speech Get-NerthusEntitySpeech log.read
GET /entities/{name}/scenes log.read
GET /entities/{name}/hits log.read
GET /entities/{name}/mentions Find-NerthusEntityMention log.read
GET /entities/{name}/timeline Get-NerthusEntityTimeline log.read
GET /entities/{name}/locations Get-NerthusEntityLocation entity.read
GET /entities/{name}/relations Get-NerthusEntityRelation entity.read
GET /logs/{stream} Get-NerthusLog (operational/request/audit) log.read
GET /audit/changes Get-NerthusChangeLog log.read
GET /audit/ledger Get-NerthusTransactionLedger currency.read
GET /audit/notifications Get-NerthusNotificationLog log.read

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.

The log-fetch routes carry the Write flag because every fetch writes the committed nerthus.logs/ transcript archive (Logs). That archive is repozytorium-transkryptow on a host configured with corpora.logi, and the lore checkout's own nerthus.logs/ on one that is not — the routes are the same either way, and so is the write gate. /logs/status reads that archive and writes nothing, so a read-only host still answers it.

Fetch logs into the archive

POST /v1/api/logs/fetch pulls transcripts into nerthus.logs/. A target is chosen from the body (request, url, or a session header); with none given it mass-fetches every session's @Logi. This is a Write route.

The two forms answer differently. A targeted fetch — request, url, or header — is one fetch, so it runs inline and answers 200 with a summary counting each URL by outcome. A corpus-wide fetch is hundreds of URLs each carrying a network timeout of its own, and the daemon serves on one thread: run inline it would answer nothing else for minutes. That form goes to a worker thread and answers 202 with a job id, which GET /v1/api/jobs/{id} reports on. Reads keep being served while it runs. ?dryRun=true stays inline, since it writes nothing.

One background job runs at a time. A second while one is out answers 409 with { "error": "JobRunning", "job": "<id>" } naming the one still running — two sweeps would write the same archive from two threads.

A plain URL, from any of those targets, is fetched in primitive or gdoc mode, and the integrations.logs.hosts allow-list decides every step of it. The daemon walks the redirect chain itself and re-checks each Location host against the allow-list before following it, up to five hops. Without that, one allow-listed host that can be made to 3xx hands the daemon an address of the redirector's choosing.

What arrived is judged before anything is written

An allow-listed host answering 200 has not thereby sent a transcript. A paste host serves its own shell page for an expired paste, and an image host serves one for an album, both as text/html with a 200 — so a fetch that trusted the status archived markup as a one-line prose transcript, and every consumer downstream then re-derived "is this actually a transcript" from the text, each slightly differently.

The content type is in hand exactly once, at the fetch, so that is where it is decided. Four checks, and each catches something the others do not:

Refused when Reason recorded
the content type is neither text/*, application/json nor application/xhtml+xml not a transcript: content type <type>
the body is empty or whitespace not a transcript: empty body
the head opens with a doctype or an <html> element not a transcript: an HTML document, not a log
the head's replacement-character share exceeds content_gate.binary_share not a transcript: a binary body, not text

A refusal takes the same .failed marker every other failure takes, with the reason above and the upstream status, so the retry curve still heals a host having a bad afternoon while an album never becomes a log. No sidecar and no raw copy are written, and the session reads as citing something that could not be fetched — which is true.

One host is read differently before the gate sees it. ntpd.eu is a page host — a notepad note answers text/html with the transcript inside <div class="content"> as <br>-separated timestamped lines — so the daemon reads that container out first, judges and archives the transcript it held, and refuses a note with no such container with the reason not a transcript: the page carries no <div class="content">. The same reading applies to an import of a saved note. No other host is treated this way, and the HTML check below still fires for every one of them.

Two deliberate exemptions. A missing content type is not a refusal: a file:// fetch inside the repository carries no headers and is a legitimate source, so an absent type falls through to the shape checks alone. And the HTML check is anchored at the start, so a real transcript whose first speaker pasted some markup still archives.

Both numbers are operator-tunable under content_gate in Configuration, where they are also justified.

The four checks above are what the gate asks when a transcript was requested. The same gate answers two other expectations — a page, where an HTML body is the content, and a binary artifact, where a text body is the failure — and a works-repository fetch uses them (Fetch a source). The reason strings name the expectation, so a refusal always says what was asked for. Nothing about the transcript path changes: it asks for a transcript and always has.

A request body is request mode instead, and so is a url that parses as a raw HTTP request: a request line, headers, a blank line, then the body. Request mode names its own endpoint and issues an arbitrary method, headers and body from the daemon's network position, past the allow-list. That form costs log.fetch.request on top of the route's log.fetch. The capability sits in no role bundle (Capabilities), so the plain log.fetch a Narrator holds reaches only the allow-listed hosts.

A cold mass fetch is thousands of transcripts, so Invoke-NerthusSessionLogFetch -ShowProgress renders a done/total bar from the route's logfetch:progress ticks (Server-Sent Events). The ticks come from the worker, and a client that wants them opens /events before starting the run.

POST /v1/api/logs/fetch
Content-Type: application/json
Authorization: Bearer <token>

{ "retryFailed": false }
await fetch("https://evocation.nerthus.pl/v1/api/logs/fetch", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ retryFailed: false }),
}).then((r) => r.json());

Response 202:

{ "job": "job_4f2c9a1b7d03", "kind": "logs.fetch", "status": "running", "statusUrl": "/v1/api/jobs/job_4f2c9a1b7d03" }

The job's own record carries the summary once it finishes:

{ "id": "job_4f2c9a1b7d03", "kind": "logs.fetch", "status": "completed",
  "result": { "Total": 0, "Fetched": 0, "Unchanged": 0, "Archived": 0, "Failed": 0, "Skipped": 0, "FailedUrls": [] } }

A targeted fetch answers the summary directly, as 200:

{ "Total": 1, "Fetched": 1, "Unchanged": 0, "Archived": 0, "Failed": 0, "Skipped": 0, "FailedUrls": [] }

Import a transcript from a local copy

POST /v1/api/logs/import archives one transcript whose bytes the caller already holds, under the name its authored URL gives it. It is a fetch minus the network, and it exists for one reason: a host can die with its URLs. krisaphalon.ct8.pl — the log-saver addon's own host, on the allow-list since the archive began — now answers every /get/ URL with three redirects into a GitLab sign-in and a 403, and its owner handed the addon's store over as files: 1 143 saved transcripts that nothing else in the ecosystem can regenerate. No other route could land one of them where a session's @Logi link resolves, because the archive name is a pure function of the URL the corpus authored — scheme included — and the fetch path derives it from a URL it is about to request, while a file:// URL names the entry after the file.

The body is { "url", "content" }, one transcript per request, plus "force": true to rewrite an entry already archived. Everything a fetch is held to holds here:

the same as a fetch
capability and gate log.fetch, and the route carries the Write flag
allow-list the URL's host must be on integrations.logs.hosts; a host off it answers 422 LogSourceRefused. An import is not a way to put a transcript from nowhere into the corpus
content gate the two shape checks a fetch judges arrivals by — an HTML document or a binary body is 422, and nothing is written
the writer the raw copy, the sidecar, the precomputed view and the cleared retry marker are written by the one function a fetch writes through, so a reparse years later reads one kind of entry

A file:// URL is refused outright: the fetch path already reads those under the allow-list's own rule for them, and an import is for a host that is gone. An entry already archived answers Archived and the bytes on disk stand; under force the same bytes answer Unchanged.

The answer names the archive entry (Name), the template the parser read (Format), how many lines and scenes it holds, and Status: Imported, Unchanged, Archived, Failed (the parser or the sidecar persist choked — the URL takes a .failed marker, as a fetch would), or DryRun. ?dryRun=true judges and parses and writes nothing, reporting what the write would have been; the example below is that form, so the fixture archive stays as it was.

POST /v1/api/logs/import?dryRun=true
Authorization: Bearer <token>
Content-Type: application/json

{ "url": "http://krisaphalon.ct8.pl/get/2026-01-01_Proba_importu_Rothe",
  "content": "[[true,\"small\",\"\",[\"Vinkor\"],\"Rothe\"],[{\"nick\":\"\",\"command\":\"entertown\",\"content\":\"Thuzal\",\"date\":1746806500},{\"nick\":\"Vinkor\",\"command\":\"\",\"content\":\"Import.\",\"date\":1746806510}]]" }
await fetch("https://evocation.nerthus.pl/v1/api/logs/import?dryRun=true", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ url: "http://krisaphalon.ct8.pl/get/2026-01-01_Proba_importu_Rothe", content: savedLogJson }),
}).then((r) => r.json());

Response 200:

{ "Status": "DryRun", "Name": "krisaphalon.ct8.pl_get_2026-01-01_Proba_importu_Rothe_<16 hex>",
  "Url": "http://krisaphalon.ct8.pl/get/2026-01-01_Proba_importu_Rothe",
  "Format": "RichJson", "LineCount": 1, "Segments": 1 }

The Segments: 1 is the rich record's entertown message read as a scene boundary — the recorder's location header, consumed as the header and not also kept as a line, which is what the ChatLog branch has always done with Thuzal. A rich transcript arrives with every line in a scene, its segments resolved through the name index untyped and then held to a Mapa or a Lokacja: a typed Lokacja ask drops the owner from the lookup and surfaces a neighbour rather than nothing, which on the saved-logs archive substituted three towns for their namesakes. And the recorder's own /dice line — a roll the parser already reads into Dice — is no longer counted as local speech; the other engine commands the recorder files as Lokalny still are (128-krisaphalon-import, rulings H128.2 and H128.4).

Parse transcript text

POST /v1/api/logs/parse parses inline content (or fetches a url) into a structured transcript: the detected Format, a LineCount, resolved Lines, and any LocationSegments. Passing content keeps it offline and deterministic.

A url that reaches request mode costs log.fetch.request here too, so this log.read route can answer 403. A plain URL string stays a primitive fetch.

POST /v1/api/logs/parse
Content-Type: application/json
Authorization: Bearer <token>

{ "content": "[19:04] Eraster: Pozdrawiam Lorda Tussala." }
await fetch("https://evocation.nerthus.pl/v1/api/logs/parse", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ content: "[19:04] Eraster: Pozdrawiam Lorda Tussala." }),
}).then((r) => r.json());

Response 200 (trimmed):

{ "Format": "Plain", "LineCount": 1, "Lines": [], "LocationSegments": [] }

Rejected: nothing to parse

With neither content nor url, parse is 400.

POST /v1/api/logs/parse
Content-Type: application/json
Authorization: Bearer <token>

{}
await fetch("https://evocation.nerthus.pl/v1/api/logs/parse", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
});

Response 400:

{ "error": "ContentOrUrlRequired", "detail": "content or url required" }

Rejected: request mode without the capability

/logs/fetch and /logs/parse both refuse a request-mode fetch with 403 when the caller's set lacks log.fetch.request, in the structured CapabilityRequired body (structured refusals). Nothing leaves the host. The refusal is raised at the one outbound choke point, before the request is issued. Here a token holding log.fetch sends a request spec to the fetch route.

POST /v1/api/logs/fetch
Content-Type: application/json
Authorization: Bearer <token>

{ "request": { "url": "https://krisaphalon.ct8.pl/get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward", "method": "GET", "cookies": { "cf_clearance": "<from a real browser session>" } } }
await fetch("https://evocation.nerthus.pl/v1/api/logs/fetch", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ request: { url: "https://krisaphalon.ct8.pl/get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward", method: "GET", cookies: { cf_clearance: "<from a real browser session>" } } }),
});

Response 403:

{ "error": "CapabilityRequired", "status": 403, "capability": "log.fetch.request" }

A token holding log.read gets the same answer from parse, because the url it sent is a raw HTTP request rather than a plain URL.

POST /v1/api/logs/parse
Content-Type: application/json
Authorization: Bearer <token>

{ "url": "GET /get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward HTTP/1.1\nHost: krisaphalon.ct8.pl\nCookie: cf_clearance=<from a real browser session>\n\n" }
await fetch("https://evocation.nerthus.pl/v1/api/logs/parse", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ url: "GET /get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward HTTP/1.1\nHost: krisaphalon.ct8.pl\nCookie: cf_clearance=<from a real browser session>\n\n" }),
});

Refresh a session's logs

POST /v1/api/sessions/{header}/logs/refresh re-pulls and re-parses the session's @Logi, returning the header and each log's fetch state. { "mode": "missing" } fetches only the uncached logs; otherwise it forces a full re-pull. The fixture session has no @Logi, so Logs comes back empty and nothing hits the network. This is a Write route.

POST /v1/api/sessions/2026-07-01,%20Eraster%20rozmawia%20z%20Tussalem,%20Anward/logs/refresh
Content-Type: application/json
Authorization: Bearer <token>

{}
const header = "2026-07-01, Eraster rozmawia z Tussalem, Anward";
await fetch(`https://evocation.nerthus.pl/v1/api/sessions/${encodeURIComponent(header)}/logs/refresh`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
}).then((r) => r.json());

Response 200:

{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Logs": [] }

Narrow the parsed sidecar

The refresh route and its read sibling GET /v1/api/sessions/{header}/logs (Sessions) both take ?include=, a comma-joined list of the parts of Parsed to return. Omit it and both routes answer with the whole sidecar: every parsed line with all thirteen of its fields.

Part Adds to Parsed
meta nothing past the header fields
segments LocationSegments
lines Lines, eight fields each: Index, Time, Channel, Segment, Speaker, SpeakerResolved, Text, Dice
mentions on every served line, Mentions: the annotation's mention candidates, offsets rebased into Textstart, end, text, lemma, kind, capitalised. Rides lines; empty on a line the annotation says nothing about, or on a transcript annotated before format 10. Answers from the sidecar, not the precomputed view — see below.

Segment is in that set because a line without it cannot be placed in the scene it was spoken in, which is what a reader asking for segments alongside lines is after.

Dice is { Roller, Sides, Result } on a line reporting a /dice roll and null on every other. The engine prints a roll as prose — * Anward rzucił kostką o 62 ściankach i wypadła liczba 3 — so the text stays exactly as the transcript had it and the fields are a reading of it. A result outside the die, or prose that merely mentions one, is not a roll: the engine never prints either, so a line shaped like one was written by hand. Sidecars parsed before this field existed carry no Dice until the archive is reparsed (POST /logs/archive/reparse).

Every projection carries the header fields Format, Template, Meta and LineCount, so ?include=meta answers with the shape of a transcript and none of its text, and ?include=segments,lines returns both lists. The query narrows Parsed alone. Every other member of a log entry, Url and Status included, comes back unchanged, and a log whose Status is Failed or NotFetched has no Parsed to narrow in the first place.

An unrecognized part is dropped rather than refused, so ?include=segments,bogus projects segments. When nothing in the list is recognized the daemon reads the request as though the query were absent and answers with the whole sidecar.

Corpus health

GET /v1/api/logs/status scans nerthus.logs/ and the .failed markers and reports the fetch state of the whole transcript corpus in one call: the counts by state, a breakdown by host, the failure reasons with their counts, and each failing URL with the sessions that reference it. The alternative is walking every session and joining each @Logi URL to the archive by hand. The scan touches the filesystem only, so no request leaves the host. status is not a stream name: the literal path is matched ahead of /logs/{stream}.

The corpus is every @Logi URL the session model carries, widened by the URLs the .failed markers name, because a URL dropped from a session block still has retry state on disk. Each URL lands in one of four states.

State Meaning
Archived a parsed sidecar is in nerthus.logs/
Failed a marker is on disk and the URL is still in the automatic retry rotation
PermanentlyFailed the same error came back integrations.logs.failed_max_attempts times running
NotFetched neither

A URL holding both an archived pair and a marker counts Archived, because the archive is what answers the read.

Member Holds
Counts Total, plus one count per state
ArchivedPairs the sidecar files in nerthus.logs/, counted by name
Hosts per host: Host, Total, and one count per state
Reasons Reason and Count
Failing one row per failing URL

A Failing row carries Url, Host, State, Reason, Attempts, IdenticalFailures, Status, Error, FirstFailedAt, LastAttempt, RetryAfterHours, and the Sessions whose @Logi names the URL. RetryAfterHours is the wait the retry curve applies at the current attempt count, and it is null on a PermanentlyFailed row, which no automatic retry picks up. A Reason has the URL-specific tail of the error text dropped, so like failures share one bucket. The three lists are ordered worst first: Hosts by Total, Reasons by Count, Failing by Attempts.

Sidecars are counted by file name rather than opened, since parsing every archived transcript to learn its URL would read the whole corpus for a status page. ArchivedPairs above Counts.Archived is therefore the transcripts on disk that no session references any more.

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

Response 200:

{ "Counts": { "Total": 0, "Archived": 0, "Failed": 0, "PermanentlyFailed": 0, "NotFetched": 0 },
  "ArchivedPairs": 0, "Hosts": [], "Reasons": [], "Failing": [] }

Speech: what an entity said

Every archived transcript already carries its lines with each speaker nick resolved to an entity. The speech routes turn that around and answer from the corpus rather than from one session at a time: what an entity said, where, with whom, and which nicks reach nothing.

One channel, and only one

The speech layer reads the Lokalny channel. A private message, a group or clan line, a whisper, and a global shout are dropped before anything is indexed, counted, or returned — no filter turns them back on, and ?channel= is not a parameter. A line with no channel marker at all counts as local: the parser stamps Prywatny on anything carrying a receiver and the game marks every other channel explicitly, so an unlabelled line in a copy-pasted transcript is local speech. Every response carries "Channel": "Lokalny" as the standing statement of that scope.

The routes read a derived index over nerthus.logs/, built on first use and refreshed when the archive or the lore model moves. Nick resolution runs at build time rather than being read back from the sidecar, so a new @alias reaches transcripts fetched years ago without refetching them. POST /v1/api/logs/index/rebuild forces a full rebuild; it costs admin.index and writes no repo data, like /name-index/rebuild (Name resolution). ?async=true runs it on a worker thread and answers 202 with a job id.

A refresh is not a rebuild. The two inputs are asked about separately, because they cost differently:

What moved What it costs
nothing the index already in memory
some transcripts — a session close fetching its @Logi re-reading those sidecars only
the lore, without moving any nick's answer one resolver pass over the distinct nicks
the lore, moving a nick — a new @alias, a rename that pass, plus a walk of the records in memory
the whole archive — a reparse, a first build reading every sidecar

Only the last row is the expensive one. This matters because the lore moves constantly during play: closing a session, granting a role and minting a key all move the model fingerprint, and none of them should cost a walk of the archive to answer the next read.

Every line one entity said

GET /v1/api/entities/{name}/lines is the route the whole layer exists for. The name resolves the way every other entity route resolves it, so an alias or a declined form reaches the same entity, and lines spoken under several nicks come back together.

Query Narrows to
speaker one in-game nick, where an entity spoke under several
session one session header
location one resolved location-segment label
from / to session dates, inclusive, YYYY-MM-DD
text lines holding a case-insensitive substring
limit / offset the page; limit defaults to 200 and tops out at 1000

Count is the full match, not the page. Each item carries Log, Url, Line, Time, Ts, Channel, Speaker, Resolved, Type, Segment, Location, LocationResolved, Date, Sessions, and Text. A text filter is the expensive form: matching on text hydrates every candidate before the page is cut, where the other filters page first.

Log and Segment together name the scene a line was spoken in, which is what lets a reader group a result set the way the transcript reads rather than as a flat list, and Line indexes that transcript's parsed lines — the triple is how a caller reaches the surrounding narration through /logs/archive/{log}. LocationResolved says whether Location is a lore place or the transcript's own wording for one.

GET /v1/api/entities/Eraster/lines?limit=50
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/entities/Eraster/lines?limit=50", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "Channel": "Lokalny", "Count": 0, "Offset": 0, "Limit": 50, "Items": [], "Entity": "Eraster" }

GET /v1/api/logs/lines takes the same query without the path name, plus entity and type, and answers across the whole cast — every local line in a date range, or every line holding a phrase. It is matched ahead of /logs/{stream}, like /logs/status.

Every scene one entity spoke in

GET /v1/api/entities/{name}/scenes is the same corpus as /lines, assembled. /lines answers with pointers — transcript, scene, line — and a reader who wants to know what was said around them has to fetch each transcript and cut the windows itself: one request per transcript, the same arithmetic in every client, and a whole transcript on the wire to show five lines of it. This route does that work where the index and the archive already are.

A scene is a transcript plus a location segment. Hits inside it that fall within a context window of each other are one run, because rendering them apart would print the same surrounding lines twice; what separates two runs is a gap, the count of the lines between them, which is how a reader tells the following breath from half an hour later.

Query Narrows or shapes
radius context lines either side of a run; default 2, capped at 200
order asc (default) or desc — the most recent scene first
speaker, type, session, location, from / to, text exactly as /lines
limit / offset the page, counted in scenes, not lines

Count is the number of scenes the filters match, and SceneLineCount on each says how many lines the scene holds in total — so a reader knows there is more outside the window without being handed it. Scenes are grouped from the index, which touches no disk; only the requested page is hydrated, so asking for ten scenes reads ten transcripts' sidecars and no others. order=desc exists because a narrator opens a character wanting the last thing they said, and answering that here spares every client the trick of paging from the end.

Each scene carries Log, Url, Segment, Location, LocationResolved, Date, Sessions, HitCount, SceneLineCount, Runs (FirstLine, LastLine), Gaps (AfterLine, Skipped) and Lines — each Line, Time, Speaker, Resolved, Text and IsHit. A line with no Speaker is narration, which the index never held: it is in the window because it is part of the conversation, not because anyone was indexed as saying it.

The Lokalny rule holds here as everywhere: the window is cut from the local lines alone, so a private message that sat between two spoken lines is not in it and is not counted in a gap either.

GET /v1/api/entities/Eraster/scenes?order=desc&radius=1&limit=10
Authorization: Bearer <token>
await fetch(
  "https://evocation.nerthus.pl/v1/api/entities/Eraster/scenes?order=desc&radius=1&limit=10",
  { headers: { "Authorization": `Bearer ${token}` } },
).then((r) => r.json());

Response 200 (the fixture corpus archives no transcript for this entity):

{ "Entity": "Eraster", "Channel": "Lokalny", "Count": 0, "Offset": 0, "Limit": 10, "Order": "desc", "Radius": 1, "Items": [] }

Where in the campaign an answer lives

GET /v1/api/entities/{name}/hits is the same query as /scenes, counted instead of assembled. A page of scenes cannot report on itself: ten newest scenes look identical whether they are all of the answer or a tenth of it, so a client that shows a result count from a page shows a number that is wrong exactly when it matters. This walks the whole match and reports it per session, which is also what a client needs to draw an answer against the campaign rather than against what it happened to fetch.

It takes every filter /scenes takes — speaker, type, session, location, from / to, text — and no limit or offset, because the aggregate is the whole match. Nothing is hydrated for display, so it costs what the filters cost; with text that still includes reading the candidates, since matching on text means reading it.

Count is matching lines and SceneCount the distinct scenes they fall in — the same grouping /scenes assembles, a transcript plus a location segment. Each entry in Sessions carries Header, Date, Lines and Scenes, newest first. A line counts under every session its transcript belongs to, and one whose transcript belongs to none is counted under a null Header rather than dropped — a transcript can be archived before its session block is written, and summing the listing has to reach Count.

A name no lore block claims is not an error here, as on every speech route: an in-game nick still speaks in the corpus, so it becomes its own subject and the answer is simply empty.

GET /v1/api/entities/Eraster/hits?text=srebro
Authorization: Bearer <token>
await fetch(
  "https://evocation.nerthus.pl/v1/api/entities/Eraster/hits?text=srebro",
  { headers: { "Authorization": `Bearer ${token}` } },
).then((r) => r.json());

Response 200 (the fixture corpus archives no transcript for this entity):

{ "Entity": "Eraster", "Channel": "Lokalny", "Text": "srebro", "Count": 0, "SceneCount": 0, "SessionCount": 0, "Sessions": [] }

The speech profile

GET /v1/api/entities/{name}/speech aggregates the same corpus into one record: LineCount, WordCount, LogCount, SessionCount, FirstSpoke, LastSpoke, and the Nicks, Sessions and Locations behind them, each ordered by line count.

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

Response 200 (trimmed):

{ "Entity": "Eraster", "Channel": "Lokalny", "LineCount": 0, "WordCount": 0,
  "Nicks": [], "Sessions": [], "Locations": [] }

Mentions, places, relations, and the merged timeline

Four reads sit beside the profile, all Lokalny, all keyed by the same resolved name, all answering the { Entity, Channel, Count, Items } envelope.

Route Answers Query
GET /entities/{name}/mentions the lines that name an entity without being spoken by it, declension-aware; a corpus scan, so it costs more than its neighbours ?from= ?to= ?limit= ?offset=
GET /entities/{name}/locations where an entity spoke: one row per location-segment label, with line counts, dates, and whether the label reached a Lokacja none
GET /entities/{name}/relations who it shares the floor with — the other speakers in the same location segment of the same transcript none
GET /entities/{name}/timeline one dated stream merging its sessions, its audit lines, and the transcripts it speaks in ?from= ?to=

from and to are inclusive YYYY-MM-DD bounds compared against the item's own date, so naming either drops an item the corpus dates with nothing. mentions pages with limit and offset and reports both back, 200 lines by default. The other three answer whole: a timeline is one stream per entity, and a places or relations row is one label or one co-speaker, so all three stay small enough to draw at once.

A relation is co-presence, not address: the explicit addressee of a line lives on the private channel, which this layer never reads, so an edge means "spoke in the same room on the same day" and nothing more. mentions is the body-mention evidence the participation graph leaves out (Sessions) — matching stems each word of a line the way the resolver stems a query, so Erasterowi lands on Eraster.

Each mentions item carries a Spans list saying where in the line the name sits: start, end, text and the tagger's label. Offsets are into that item's own Text, so Text.substring(start, end) is the mention and a caller never subtracts a line offset it was not given. They come from the annotation sidecars; which entity a span belongs to is still resolved by this daemon, because the tagger says persName and never which Postać.

Spans is [] — never absent — on a repository with no annotation tree, on a sidecar whose recorded hash no longer matches the transcript, and on one written before the format carried a line index. All three are the answer this route gave before the layer existed, and a stale annotation is refused rather than trusted: an offset into text that has moved is confident nonsense.

A locations row is Location, Resolved, Lines, Logs, FirstDate and LastDate, ordered by line count. A relations row is Name, Type, Contexts, Lines, FirstDate and LastDate, ordered by shared contexts. A timeline item carries Date and Kindsession, audit, or speech — plus that kind's own fields: the header, title and narrator of a session; the op, field, old and new values, actor and UTC stamp of an audit line; the archive name, URL, line count and referencing sessions of a transcript. A speech item is one per transcript rather than one per line, and same-day items sort by kind.

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

Response 200:

{ "Entity": "Eraster", "Channel": "Lokalny", "Count": 0, "Offset": 0, "Limit": 200, "Items": [] }

The timeline, bounded to 2026. The fixture archives no transcript for Eraster and no write has touched him, so the one item is the session he played:

GET /v1/api/entities/Eraster/timeline?from=2026-01-01&to=2026-12-31
Authorization: Bearer <token>
await fetch(
  "https://evocation.nerthus.pl/v1/api/entities/Eraster/timeline?from=2026-01-01&to=2026-12-31",
  { headers: { "Authorization": `Bearer ${token}` } },
).then((r) => r.json());

Response 200:

{ "Entity": "Eraster", "Channel": "Lokalny", "Count": 1, "Items": [
  { "Date": "2026-07-01", "Kind": "session", "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward",
    "Title": "Eraster rozmawia z Tussalem", "Narrator": "Anward" }
] }

Where he spoke. Both place reads take the name and nothing else:

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

Response 200 (the fixture corpus archives no transcript for this entity):

{ "Entity": "Eraster", "Channel": "Lokalny", "Count": 0, "Items": [] }

And who shared the floor with him:

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

Response 200:

{ "Entity": "Eraster", "Channel": "Lokalny", "Count": 0, "Items": [] }

The speaker roster

GET /v1/api/logs/speakers lists every nick the corpus speaks under, worst first by line count: Nick, Resolved, Type, Lines, Logs, FirstDate, LastDate, and Drifted. A nick with Resolved: null reaches no entity, which is a missing @alias, so the roster doubles as a hygiene worklist the same way /entities/integrity does; ?unresolved=true narrows to exactly those. Drifted marks a nick whose archived verdict no longer matches the current lore — the sidecar was written before the lore moved, and the index has already corrected it.

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

Response 200:

{ "Channel": "Lokalny", "Count": 0, "Unresolved": 0, "Drifted": 0, "BuiltAt": "2026-07-28T09:00:00.0000000Z", "Items": [] }

A transcript's mention candidates

GET /v1/api/logs/archive/{log}/mentions answers the annotation's mention candidates for one transcript — the nominal groups Nerthus.Lang could not resolve to a name ("Elfka", "Kapłan", "Zakon") — keyed by line Index, offsets rebased into each line, six fields each, and nothing else: { Log, Count, Lines: { "<Index>": [ { start, end, text, lemma, kind, capitalised } ] } }. A candidate the pipeline already put inside an entity span is not repeated. An unannotated transcript, or one annotated before format 10, answers Count: 0 rather than an error, which is what every transcript answered before the annotation layer existed. A name that walks out of the archive directory is a 404, as on the archive read.

Why a route of its own, beside ?include=mentions. Measured 2026-09-04 on a 2 071-line evening with 624 candidates: the archive read answers from its precomputed view in ~17 ms warm, and the same read with ?include=mentions in ~260 ms, because the include leaves the view and materialises every line; the sidecar read itself is cached and costs nothing after the first call. This route answers from that same cache and carries only the candidates — 63 KB and ~17 ms warm for that evening's 626 candidates, ~32 ms with the view read in flight beside it — so a reader keeps the view for the lines and pays one small request for the marks. The dashboard reads it that way: lines and candidates in flight together, merged before a line is drawn.

A transcript's runs

GET /v1/api/logs/archive/{log}/runs answers which lines belong together. Ruling H126.6 (2026-09-06): a narration run is the chain of unsigned lines — a pastebin log names no author — whose timestamp gaps are under three minutes; a signed speaker's unbroken stretch inside the same window is the second grouping and is kept apart from it, because the game cuts a message at 200 characters and a player types on. Every line belongs to exactly one run, and a line with no timestamp starts a run of its own: a gap that cannot be measured is not a gap under three minutes.

{ "status": 200, "exists": ["log", "source", "count", "runs", "agree", "disagreements"] }
{
  "log": "pastebin.com_raw_RNdb9gG3_0C19B82C2733E5ED",
  "source": "both",
  "count": 3,
  "runs": [
    { "kind": "narration", "speaker": "", "first": 97, "last": 98, "lines": [97, 98] },
    { "kind": "speech", "speaker": "Alyssa Alivolle", "first": 99, "last": 101, "lines": [99, 100, 101] },
    { "kind": "narration", "speaker": "", "first": 102, "last": 102, "lines": [102] }
  ],
  "sidecar": [97, 99, 102],
  "agree": true,
  "disagreements": []
}

Two implementations of one ruling, and neither overrules the other. nerthus_lifecycle.runs computes this partition beside the parse and format 11 carries it in the sidecar as boundaries — the line index each run begins at, since a run is contiguous by construction and this corpus runs 0.68 runs per line, about 150 000 archive-wide. The daemon computes the same partition from the archived timestamps, for a transcript with no sidecar and as the cross-check. source is computed when the sidecar carries no runs (a format-10 tree, or a transcript the annotation has never seen), and both when it does — and then agree says whether the two partitions are the same and disagreements lists every boundary only one of them draws, each as { line, only: "daemon" | "sidecar" }. A disagreement is a listed defect, the same settlement the quantity grammar's two implementations live under.

A name that walks out of the archive directory is a 404, as on the archive read.

One archived transcript

GET /v1/api/logs/archive/{log} reads one transcript by its archive name — the sanitized URL plus its short hash, the Log member every line carries. Every other read of a transcript goes through a session that references it, so this is the only path to an archive entry no @Logi names any more. The parsed payload is stripped to its local lines and carries LocalLineCount beside LineCount; ?include= narrows it exactly as it narrows the session route. A name that walks out of the flat archive directory is a 404, not a resolved path.

GET /v1/api/logs/archive/nie-ma-takiego-transkryptu
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/logs/archive/nie-ma-takiego-transkryptu", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 404:

{ "error": "Transcript not archived: nie-ma-takiego-transkryptu" }

Re-parse the archive after a parser change

POST /v1/api/logs/archive/reparse re-reads every archived transcript from the raw copy kept beside its sidecar and rewrites the stored parse with what the current parser makes of it. A parser improvement otherwise reaches only the transcripts fetched after it, and re-fetching is not the remedy — a paste host expires, and the archive is the copy of record. Every fetch writes that raw copy for exactly this, so the sweep is offline and touches no allow-listed host.

url, fetchedAt, and contentHash describe the fetch, not the parse, and are carried over untouched: a reparse must not read downstream as a refetch. A sidecar whose raw copy predates the raw-copy archive is reported Skipped, never silently left on an older parse. The summary counts Reparsed (the stored parse and the current one disagree), Unchanged, Skipped, and Failed, and Items reports the line, local-line, segment and dice-roll counts before and after for each transcript.

?async=true hands the sweep to a worker thread and answers 202 with a job id instead, the same envelope /logs/fetch uses for its corpus-wide form. The inline form stays the default here, because an operator reparsing a corpus is usually reading the deltas it reports.

The comparison is those counts, so a parser that learns to read something new has to add a count for it or the sweep reports a corpus already current and rewrites nothing — which is what DiceAfter is doing beside the line counts. ?force=true rewrites every sidecar whether or not the comparison spots a difference: the escape hatch for a change the delta cannot see, where the alternative is an archive frozen in the shape of the parser that first read it.

This is a Write route: it rewrites nerthus.logs/. Add ?dryRun=true to get the same corpus-wide delta with nothing written, which is how a parser change is inspected before it lands. When the sweep does rewrite a sidecar, the derived speech index is rebuilt behind it. The fixture corpus archives no transcript, so the example sweeps an empty archive.

POST /v1/api/logs/archive/reparse?dryRun=true
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/logs/archive/reparse?dryRun=true", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "Total": 0, "Reparsed": 0, "Unchanged": 0, "Skipped": 0, "Failed": 0, "DryRun": true, "Items": [] }

Read a log stream

GET /v1/api/logs/{stream} returns the last 200 lines of a rolling stream as the { stream, count, items } envelope. The stream is one of operational, request, or audit. Here, the operational stream.

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

Response 200 (trimmed):

{ "stream": "operational", "count": 0, "items": [] }

The request stream logs each HTTP request:

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

The audit stream logs each mutation:

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

Rejected: unknown stream

A stream name outside the three is 404.

GET /v1/api/logs/nieznany
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/logs/nieznany", {
  headers: { "Authorization": `Bearer ${token}` },
});

Response 404:

{ "error": "UnknownStream", "detail": "Unknown stream" }

The change audit

GET /v1/api/audit/changes is the audit stream under an audit-facing path — the { stream, count, items } envelope of recorded mutations.

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

Response 200 (trimmed):

{ "stream": "audit", "count": 0, "items": [] }

The transaction ledger

GET /v1/api/audit/ledger returns the dated ledger of applied @Transfer moves, newest first, as { count, items } — needs currency.read. No transfers have been applied, so it is empty.

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

Response 200:

{ "count": 0, "items": [] }

The notification log

GET /v1/api/audit/notifications reconstructs @Intel notification intent from the sessions as { count, items }; filter with ?operation=. The fixture session records no @Intel, so it is empty.

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

Response 200:

{ "count": 0, "items": [] }