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.
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.
Response 200 (trimmed):
Rejected: nothing to parse¶
With neither content nor url, parse is 400.
Response 400:
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.
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:
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.
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.
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:
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 Text — start, 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.
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.
Response 200:
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.
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.
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.
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
Kind — session, 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.
Response 200:
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:
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:
Response 200 (the fixture corpus archives no transcript for this entity):
And who shared the floor with him:
Response 200:
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.
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.
{
"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.
Response 404:
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.
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.
Response 200 (trimmed):
The request stream logs each HTTP request:
The audit stream logs each mutation:
Rejected: unknown stream¶
A stream name outside the three is 404.
Response 404:
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.
Response 200 (trimmed):
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.
Response 200:
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.
Response 200: