Skip to content

Logs, audit, and Discord delivery

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

This page covers everything the daemon writes about itself and everything it sends to the outside world. It has three parts, separate mechanisms that share one subsystem in the code. The log streams under .nerthus/log/ (JSONL — one JSON object per line) come with the audit-derived read cmdlets. The game-log fetch + parse pipeline pulls play transcripts from @Logi URLs and extracts location segments for the traversal graph. And Discord channel delivery posts PU and @Intel notifications to each player's personal channel as a bot, resolving the channel by name.

For the plain-language introduction, see the logs overview.


Concepts

Term Meaning
Log stream An append-only JSONL file under .nerthus/log/ recording daemon activity
Audit log audit.jsonl — the durable record of every state mutation; the substrate behind Get-NerthusEntityHistory and Get-NerthusChangeLog
Game log / transcript An external play-session log referenced by a session's @Logi tag (krisaphalon, Pastebin, Google Docs)
Template A transcript's content shape: basic (raw text) or rich (the log-saver JSON record)
Sidecar The <name>.json file holding a transcript's parsed, resolved structure, next to its raw <name>.log
Log archive nerthus.logs/ — the ONE committed transcript store (raw + sidecar per URL), readable offline in every clone. In repozytorium-transkryptow where the host has one (corpora.logi), else at the lore repo root
.failed marker A file in the gitignored cache/logs/ recording that a transcript URL could not be fetched, to suppress redundant retries
Discord channel The channel a person is reached on — the @discord tag (kanał, kategoria[, cel]) on their ## Osoby block in nerthus.contributors.md — the private channel the bot resolves by name and posts to. The optional third component names the capacity it serves; player notices ask for gracz
@Intel A session directive that targets an entity with a private message routed to its @discord channel
Delivery log deliveries.jsonl — the record of what the daemon actually sent over Discord

The Polish session tags this subsystem reads are @Logi, @Intel, @Lokacje, @PU, @Narrator, and @Data. See the session model for the full session schema and the PU model for the monthly PU batch that triggers notifications.


Log streams (.nerthus/log/)

The daemon writes four separate JSONL streams. Each line is one self-contained JSON object; the files are append-only, readable with any tail. All four roll into archived segments past a size bound — rolling moves the live file aside and deletes nothing, so the history is whole and no single file grows without limit inside the lore volume these streams live in.

.nerthus/log/
  operational.jsonl   # daemon lifecycle + import warnings
  operational.1.jsonl # archived segments; .1 holds the oldest lines
  request.jsonl       # one line per HTTP request (method, route, status, ms)
  request.1.jsonl
  audit.jsonl         # one line per state mutation (source of truth)
  audit.1.jsonl       # archived segments; nothing here is ever deleted
  events.jsonl        # one line per durable event, by sequence
  events.1.jsonl

None of the streams ever carries a bearer token, the Discord bot token, or request-mode fetch credentials — that privacy invariant holds across this whole subsystem.

operational.jsonl

Sparse daemon telemetry: daemon ready at boot (with port, pid, entity/session counts, schema), listening on <prefix> from the serve loop, and source change detected; rebuilding model from the self-heal. The importer adds one level: warn, comp: import line per duplicate entity it drops, and one comp: import line per roster bullet it moves into a charfile during adoption — action: note-moved, note-duplicate when the bullet already exists there, or note-unresolved when the note's character cannot be resolved (the note is left verbatim in Gracze.md); see the import model.

{"ts":"2026-06-30T08:01:14Z","level":"info","comp":"model","msg":"source change detected; rebuilding model"}

request.jsonl

One line per HTTP request after the middleware chain resolves (see the API reference): { method, route, status, ms } plus the timestamp — nothing else. This stream is for ops/debugging and carries no caller identity by design: these files sit inside the lore volume, and writing identities into a corpus is a decision with a retention question attached to it. Who did something is answered by audit.jsonl below, which names an actor and is the record a mutation is judged against.

{"ts":"2026-06-30T08:05:22Z","method":"POST","route":"/v1/api/workflows/award-pu","status":200,"ms":640}

events.jsonl

One line per durable event the daemon publishes — the same events the SSE stream names, minus the progress ticks, which describe a run that is over by the time anyone reads the file. The line carries the event's sequence, its type, this host's town, and the payload nested under data, so an event cannot shadow its own envelope with a type or ts key of its own.

{"ts":"2026-07-30T09:14:02Z","seq":1841,"type":"sync:completed","town":"werbin","data":{"action":"fast-forward"}}

The stream and the file answer different questions. A subscriber that is connected reads /events; one that was away — a monitor, a laptop that slept — reads GET /events/journal?since=<seq>, because the replay buffer holds a bounded window and can say nothing about what fell out of it. server.event_journal turns the file off, and the stream is unaffected either way.

A sequence counts what one host published, so a reader across several hosts keeps a cursor per host. Where a cursor names a sequence older than the oldest line still in reach, the route says truncated and names oldestSeq rather than answering as though the gap were quiet.

audit.jsonl — durable source of truth

Every write route records its write, and the dispatcher is where that is true. A handler with something to say — which entity, which field, what it held before — says it through the AfterWrite-family hooks, and the PU batch, @Transfer applies and ACL/identity/governance changes emit theirs directly from their services. A write route with no such path still records one: the write path appends { op: route.<path>, actor, method, route, status } after a successful, non-dry-run write, so a route added next month inherits an audit line rather than joining a population nobody counted. A dry run and a refusal are not audited — a stream that recorded attempts would stop being a record of what happened.

The file is never truncated and never rewritten, and it rolls into numbered segments like the other three: rolling MOVES the live file aside and deletes nothing, so the record is whole and no one file grows without limit inside a corpus checkout. Soft-deletes (@status: Usunięty — see the entity model) are recorded like any other write. The audit line is appended only after the state write lands (Architecture owns the atomic write discipline), so audit.jsonl never claims a change that is not on disk.

Every line carries ts (UTC ISO-8601), op, and actor — the resolved principal behind the write: machine (the machine token), gracz:<margonem id> (an identity-bound session token, e.g. a bot-routed action), api:<margonem id> (an API-key caller — Permissions), or token:<name> (a named token) — plus per-op fields:

op Extra fields Emitted by
entity.write entity, field, old, new every index tag write, incl. the automatic Aktywny → Nieaktywny demotion of a player's previous Postać
entity.softdelete entity soft-delete
session.write entity (the session header) session create / edit
session.distribute session, targets[] session distribution
character.info.* (add / update / remove) entity (the Postać), detail (the entry text) the charfile Dodatkowe informacje writes — the /characters/{name}/info routes
pu.award (per Postać) entity, field: pu_suma, old, new, sessions[], batch the PU batch apply — one line per mutated Postać
pu.award (batch) batch, characters (count) the AfterPuAwarded hook — one summary line per batch
currency.write entity; balance changes add field: ilość, old, new holding create / patch
currency.transfer session, moves (count) an applied @Transfer block
capability.grant / capability.revoke entity (Margonem id), new (the capability) ACL changes
identity.link entity (Margonem id) Discord ↔ Margonem linking
governance.write entity (the narrator nick) an Uprawnienia edit
sync.publish mode, branch, sha, files a sync tick that published lore bytes to origin (Sync); routine converge/no-op ticks land in the operational stream
map.checkup entity, field: url, old, new an applied CDN version bump from the map-checkup sweep (Locations model)

A monthly PU batch that credits Eraster therefore leaves two kinds of line — the per-character mutation (this is what Get-NerthusEntityHistory finds) and the batch summary:

{"ts":"2026-08-01T03:16:05Z","op":"pu.award","actor":"machine","entity":"Eraster",
 "field":"pu_suma","old":"127.90","new":"129.10",
 "sessions":["2026-07-01, Eraster rozmawia z Tussalem, Anward"],"batch":"2026-07"}
{"ts":"2026-08-01T03:16:06Z","op":"pu.award","actor":"machine","batch":"2026-07","characters":2}

Log & audit read cmdlets

These cmdlets are pure readers; they never mutate:

  • Get-NerthusLog — tail one raw stream (operational, request, or audit).
  • Get-NerthusChangeLog — a quick tail of the audit stream, every op class.
  • Get-NerthusEntityHistory — every audit line touching one entity: creation → field changes (incl. per-Postać pu.award) → soft-delete.
  • Get-NerthusTransactionLedger — applied @Transfer moves from the transaction ledger (state, not audit).
  • Get-NerthusNotificationLog@Intel directives in session sources: what should have been notified. Diff it against the delivery log to find sends that failed.
  • Get-NerthusDiscordDeliveryLog — the delivery log: what was sent.

Routes, parameters, response envelopes, and capabilities: API reference.


Game-log fetch + parse

Play transcripts live outside the repo — on krisaphalon.ct8.pl, Pastebin, or Google Docs — and are referenced by each session's @Logi tag. This pipeline fetches them into the committed nerthus.logs/ archive and parses them into structured lines and location segments that feed the traversal graph in the location model.

A real @Logi line from Wątki/Intrygi w Thuzal.md:

- Logi: https://krisaphalon.ct8.pl/get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward oraz https://nerthus.pl/logizsesjierasteraztussalem

A session may reference multiple URLs (joined by oraz / whitespace), and the same URL may be shared across sessions — both are deduplicated before fetch.

Invoke-NerthusSessionLogFetch — single or mass fetch

One entry point fetches transcripts for one log or for the whole corpus — the body chooses the target (first one present wins, in this order):

Body field Target
request one authenticated log (see modes below)
url one log URL
header one session's @Logi URLs
(none) mass: every @Logi across all sessions, deduped

Three fetch modes (selected per URL, because some logs sit behind auth):

  • primitive — a plain GET scrape (the default). The target host must be in the integrations.logs.hosts allow-list (krisaphalon.ct8.pl, pastebin.com, docs.google.com, drive.google.com, imgur.com, wklejamy.pl, ntpd.eu). ntpd.eu is a page host: a notepad note answers text/html, and the transcript is the <div class="content"> inside it — <br>-separated timestamped lines. The daemon reads that container out BEFORE the content gate judges what arrived, archives the transcript it held as the copy of record (the shell around it is the host's, not the evening's), and refuses a note with no such container the way it refuses any non-transcript. Every other host is judged exactly as before, so an expired paste's shell page is still «an HTML document, not a log». The note's canonical form carries a trailing slash, which the resolver adds (the bare form answers 301). A Docs link is asked for as its text export, which answers 307 to doc-XX-XX-docstext.googleusercontent.com; the walk refuses that hop unless the committed config names googleusercontent.com as well — the list in .nerthus/config.json replaces the default whole, so spell every host.
  • gdoc — a Google Docs/Drive link is rewritten to its public export URL (…/document/d/<id>/export?format=txt, …/uc?export=download&id=<id>) for "anyone-with-the-link" documents. Docs/Drive URLs are auto-detected and rewritten even without asking for this mode.
  • request — for an authenticated endpoint, the caller supplies the whole request. It is either a structured spec { url, method, headers, cookies, body } or a raw HTTP request string (request-line + headers + blank line + body), which the daemon parses. The daemon performs the upstream call with those credentials; the supplied headers/cookies are used for the fetch and never written to any log stream. request mode is an explicit opt-in and so bypasses the host allow-list.

A file:// URL reads a local transcript committed inside the lore repo. Because @Logi values are data-driven — whoever authors a session block picks them — the daemon refuses any file:// path that resolves outside the repo root or anywhere under .nerthus/ (tokens, signing key, private state). No mode, request included, bypasses that containment — and the containment is the lore root alone: a file:// path into the separate transcript-archive checkout is refused too. (No lore session block has ever carried one — the hand-saved Logi/ folder, now in repozytorium-transkryptow, was only ever linked schemeless, which the session grammar does not read as a log URL.)

The daemon collects the target URL(s), normalizes and deduplicates them, then fetches each one sequentially — the daemon is single-consumer — honoring integrations.logs.fetch_timeout_ms per request:

  • URL normalizationpastebin.com/acLUYdRapastebin.com/raw/acLUYdRa; http://https://; trailing slashes trimmed. A bare krisaphalon /get/<slug> is fetched as /get/<slug>.json, the addon's own richer form of the same transcript; the archive name still comes from the URL the corpus wrote, so a repository carrying both spellings keeps one archive entry rather than two. A closing bracket a Markdown link left on the address — …/get/<slug>) — is trimmed before any of this, balanced first, so a path that legitimately contains one survives.
  • The archive store — each URL maps to a filesystem-safe name under the committed nerthus.logs/ (<name>.log raw text + <name>.json sidecar); an archived transcript is served straight from disk with no HTTP call. Fetching writes repo data, so both fetch routes carry the Write flag and pass the write gate (API reference) — a read-only or stale instance cannot fetch.
  • Two gates before anything is written — a fetch checks what arrived before it archives it. A content type outside text/* and application/json is refused by name, and a body that opens with a doctype or an <html> element is refused as an error page: Imgur answers 200 text/html, so the type alone lets it through. The sniff anchors at the start, so a transcript whose own text quotes a tag still archives, and an absent content type is not a refusal — a file:// fetch inside the repo carries no headers. A refusal writes the same .failed marker any other failure does, so the TTL retry still heals a transient one. This is what stops the archive collecting HTML error pages that parse as prose.
  • .failed markers — any failed fetch (4xx/5xx/timeout, or a transcript the parser rejects — one poisoned record never aborts the rest of a mass batch) writes a <name>.failed sidecar containing the URL, error, status code, UTC timestamp, and the sessions that reference it. Subsequent runs skip marked URLs unless -RetryFailed is passed or the marker is older than integrations.logs.failed_ttl_hours (default 24), in which case it is retried automatically — a transient 429/5xx never becomes a permanent hole. Set the TTL to 0 to disable auto-retry. A later success deletes the marker.

Each successful fetch is parsed and its sidecar persisted in the same step. The call returns a summary: Total, Fetched, Unchanged (a forced refetch whose content hash — SHA-256 of the whitespace-stripped transcript text, the same reflow-immune semantics as session integrity hashing — was identical to the archived copy), Archived (satisfied offline from the archive), Failed, Skipped, and the FailedUrls list. -WhatIf skips the HTTP call on the client and returns nothing — there is no server-side preview.

What the log-saver's address says on its own

Kris Aphalon's log-saver addon writes a transcript to krisaphalon.ct8.pl under a slug shaped YYYY-MM-DD_Tytul_slowa_Imie_Nazwisko. Two facts ride in that address and neither is in the session block:

  • The tail names the character who saved it — the session's reporter. Matching is suffix-greedy against entity names and their aliases, longest run of underscore-separated tokens first, because a name spans several tokens and its last one is often a word the index also holds alone: Brimm_Schadenfreude must answer Brimm Schadenfreude, not Schadenfreude. Slugs are ASCII where the corpus is Polish, so the match folds diacritics. Only a person is accepted; half the slugs end in a place name, which is the title's last word.
  • The date is when the transcript was saved, against the header's declared, in-fiction day. Both are recorded and neither corrects the other, for the same reason search refuses to take a date from a git commit: they are two calendars, and on the campaign corpus 41 evenings disagree.

The reporter is not the narrator. For a council-adjudicated declaration — headed Rada — it is the only per-person attribution the record has, which is why it is a field of its own rather than a correction to the narrator. It is derived from the address alone, so it survives a transcript that can no longer be fetched, and it stays a function of the committed tree: any host holding the commit answers the same.

The addon's .json form additionally carries its own narrator and player list, which the parser reads into the rich template below. Those feed the normalization worklist as a second witness — a header saying Rada where the addon names a player is a row for a human — and never overwrite the header.

Nerthus.LogParser — templates and format detection

Parsing runs the compiled Nerthus.LogParser (see Architecture) — at fetch time for the sidecar, or standalone via Get-NerthusSessionLogParsed (raw -Content, or a -Url fetched ad-hoc under the same host allow-list, nothing persisted). Content arrives in one of two templates. A JSON document selects the rich template; anything else is basic text, disambiguated by scanning the first ~30 non-empty lines:

  • ChatLog (basic) — timestamped game-engine copy-paste. Two or more framed lines select this format, a [HH:MM] clock and a channel tag counting alike. Lines parse into Time, Channel, Speaker, and Text. Non-timestamp lines are location headers that split the transcript into segments.
 Rezydencja Tussal
[13:22] [Lokalny] Lord Tussal: Proszę, herbata jeszcze gorąca.
[13:23] [Lokalny] Eraster: Dziękuję, usiądźmy.

The split paste. Copying out of the game chat commonly breaks a message in two, leaving [HH:MM] and its channel marker alone on one line and the message on the next. The parser rejoins the pair before reading the transcript, so both halves land on one Line. This matters because the format's own header rule treats every non-timestamp line as a location header — left unjoined, the message half becomes a spurious segment and the timestamp half an empty Text. A timestamp whose next content line is another timestamp stays as it is: that message really was empty.

[15:02] [Lokalny]
Barbara ułożyła strzały - te ze specjalnymi grotami.

The channel vocabulary. A tag following a clock is read as written. A tag standing alone, with no clock in front of it, is read only when it names one of Lokalny, Prywatny, Grupowy, Klanowy, Szept, Systemowy, Globalny, Handlowy. That closed set is the whole guard, because a narrator's own bracketed aside ([notatka] …) opens identically and only the vocabulary tells the two apart. Outside the set such a line keeps its brackets inside Text and carries no channel, which the speech layer reads as local speech.

  • Prose (basic) — hand-written narrative summaries (the fallback). A line opening with a nick-shaped Nick: prefix, or with the old interface's «Nick», yields a Speaker and keeps the remainder as Text; everything else is narration kept whole. The nick shape is one to four capitalized words with nobiliary particles allowed inside (Moira von Tepes), and it is what separates a speaker from the colons ordinary Polish narration is full of — Konkretniej zaś: skóra i słój. stays narration. Location headers are short lines (≤60 chars) after a blank line carrying no speaker prefix.
Ogród Tussala

Narrator: Popołudnie było ciepłe i spokojne.
Eraster: Burmistrz Czemkin to spore utrapienie.
«Lord Tussal» Herbata jeszcze gorąca.

The old interface. Transcripts copied out of the legacy Margonem client wrap the nick in guillemets rather than closing it with a colon, and name the addressee of a private line as «Author -> Receiver» — the same arrow convention the rich record uses. Such a line carries Receiver and a Prywatny Channel; the guillemets frame the nick themselves, so this form is exempt from the nick-shape test.

  • RichJson (rich) — the raw JSON record produced by the narrator log recorder (Kris Aphalon's log-saver, the upstream of every krisaphalon.ct8.pl URL). Three shapes are accepted: the upload form — a two-element array of a metadata header plus the message list — the recorder's local download form, an object keyed by epoch-second timestamps, and a bare message array with no metadata header (e.g. a hand-trimmed record). All carry per-message fields the rendered text loses: the classified narration command, the exact timestamp, and the private-message receiver.
[
  [true, "large", "Eraster odwiedza Lorda Tussal.", ["Eraster", "Lord Tussal"], "Anward"],
  [
    {"nick": "Anward", "command": "nar nar0", "content": "Rozległo się pukanie.", "date": 1782912120},
    {"nick": "Anward", "command": "dial dial1", "content": "Lord Tussal,Proszę, herbata jeszcze gorąca.", "date": 1782912123}
  ]
]

The metadata header lands on the parse result as Meta (Success, Size, Synopsis, Players, Narrator); the download form has no header, so its Meta is null. Per message, the parser derives:

Field Derivation
Author The recorded nick; a A -> B private nick splits into Author + Receiver
Channel The command's channel prefix: privPrywatny, grpGrupowy, clanKlanowy, globalGlobalny. With no prefix, an A -> B nick is Prywatny and a nick naming one person is Lokalny
Command The normalized narration command (nar0nar6, dial1dial666, me, sys, map, …); a plain say carries null
Speaker The in-fiction voice: a dial* line's impersonated name (the content text before the first comma), the Author for a say or me line, null for narration and system lines
Time, Ts Ts is the epoch second; Time is its UTC HH:mm projection — a recorder-corrupted epoch outside the representable date range degrades to a null Time, never a parse failure

A rich transcript has no location headers, so its LocationSegments is empty — movement evidence for the traversal graph then comes only from the session's @Lokacje route.

Fragment merging. The game caps a chat message at 200 characters, so the community chat tooling splits long narration into parts and re-prefixes each with its command — a rich transcript is full of fragments that were authored as one message. The parser reassembles them with the same rule the in-game chat merger uses: a line continues its predecessor when Author, Channel, Command, Receiver, and Speaker all match and the timestamps are at most 5 seconds apart. Merged lines join their Text with a space and count the absorbed fragments in MergedCount.

All three formats yield a uniform shape: Lines[] (Index, Time, Channel, Speaker, SpeakerResolved, SpeakerType, Text, Segment, Receiver, plus the rich-only Author, Command, Ts, MergedCount) and LocationSegments[] (Index, Raw, Resolved, StartLine, EndLine); the result also carries Format (ChatLog/Prose/RichJson) and Template (basic/rich). The daemon then resolves names in the parsed structure — both passes disable fuzzy matching, because a transcript token is Polish prose or a proper name and edit-distance matching over-matches (karczmie must not fuzzy-match an unrelated entity); see Name resolution:

  • Segments — each segment's Raw header runs through the resolver filtered to -Type Lokacja; the canonical hit lands in Resolved, and a non-location header (a Prose document's Charakter/Wygląd heading) resolves to null by design. The importer separately matches the same Raw headers against the Mapa catalogue to derive @drzwi (auto) doors — the location model owns that derivation.
  • Speakers — resolution runs over every parsed line carrying a Speaker, which all three formats produce: each distinct in-game nick goes once through Resolve-NerthusSpeakerName, and each line carries SpeakerResolved and SpeakerType, null for a nick that reaches no person. This makes "which logs does this Postać actually speak in" answerable off the parsed line stream, and the speech layer indexes it corpus-wide.

    A place is not a speaker. The index holds every word of three characters or more in a multi-word name, so a shopkeeper's genitive inside the name of his shop reaches the shop: the nick Kendal reaches Kuźnia Kendala and Ambasador reaches Siedziba Ambasadora Elancji, both with the confidence of a declension hit. Resolve-NerthusSpeakerName therefore keeps only owners of the three types a nick can name — NPC, Postać, Gracz, which is the whole of what the corpus speaks under — and answers null otherwise. Leaving the nick unresolved is the point: an unresolved nick is what the speaker harvest and ?surface=speaker look for.

    The filter runs after resolution rather than as a -Type argument. -Type filters inside the index lookup, so it drops an owner from the candidate set instead of rejecting the answer, and a per-type call can surface a different owner rather than none — a type preference asked first cannot see that Nurosen is a player's whole block name while Nurosen Mały merely begins with that word.

The parsed object is persisted as the JSON sidecar: url, fetchedAt, contentHash, and parsed — no session list. The log → sessions reverse link is derived live from session @Logi metadata, so it can never go stale; the archived sidecar carries no sessions[] snapshot (only .failed markers keep a sessions snapshot, as transient triage context). A session's logs are therefore served straight off disk, with no re-fetch and no re-parse. Get-NerthusSessionLog — the one-call "session with its logs" path — returns each @Logi URL with a per-log Status (Fetched/Failed/NotFetched), FetchedAt, ContentHash, and Parsed; -Refresh forces a full re-pull + re-parse, and -FetchMissing fetches only the not-yet-archived logs. Consecutive Resolved segments form the per-transcript movement chain that the map traversal graph merges alongside session @Lokacje routes.

Narration in the speaker column

A framed line — one carrying a [HH:MM] clock or a channel tag — has its X: y cut without any test of X, because the engine's framing stands in for the test. Polish narration is full of colons, so prose lands in the speaker column:

[14:55] Sytuacja wyglądała następująco: Riv trzymała za rękę Ren w efekcie mając możliwość ją natychmiast obezwładnić, lub coś innego zrobić.
[23:07] [Lokalny] Poza Ernestem, najemnicy Anwarda nazywali się następująco: Kusznik Tomasz, Łucznik Ryszard, Zbrojny Roman i Zbrojny Stefan.

Each such cut mints a speech record and an attendance row for a person who does not exist, and both feed the alias and speaker worklists derived from the corpus. A reclassification pass folds them back into narration. It runs on the parse before the two resolution passes above and before the sidecar is written, so a folded segment reaches neither the name index nor disk. The parser stays lore-blind, and this pass is where the lore is read.

A candidate is a speaker string that stands exactly once in its transcript's speaker column, carries no Receiver, and was not framed in guillemets. Recurrence is the first reading, because a session's cast is a handful of strings each speaking dozens of times while a sentence is written once — Anward and Karendar speak thousands of lines apiece and are never candidates. The two exclusions hold back the forms whose text a fold would have to invent: a guillemet nick was never followed by a colon, and a private line's author was split off an arrow.

A candidate folds when it reads as narration and the name index does not answer to it. What reads as narration depends on the framing:

  • Framed — three words or more, and either it fails the nick shape or it carries a word the published morphology read as a verb. One- and two-word segments are never reconsidered. That is where the cast is (Glieve, Riveth, Achalen), and a shape test reaching them would turn speech into narration.
  • Unframed — the prose path, where the parser has already applied the nick shape. A verb hit alone folds the line, at any word count. Otrzymano: [Złoty skarabeusz] has the shape of a name and the grammar of a sentence.

The name index has the last word over both readings, on the condition that it answers with a person. palatyn Atropos Regius stands once in its transcript and fails the nick shape on its lowercase title, and the index still reaches the NPC Atropos Regius; without that clause the pass converts a resolving speaker into narration. The lookup is the one SpeakerResolved makes, so a segment reaching a house is not rescued by it — a house cannot be the one talking, and the index reaches houses from the names of the people who own them. It is fuzz-free for the reason the rest of this pipeline is: edit distance over Polish prose would hand narration a name the index never held.

A fold writes the segment and its colon back to the head of the line's Text and clears Speaker. The line then reads as the narrator wrote it, and every consumer downstream — the sidecar, the speech layer, the worklists over both — sees narration rather than a voice.

Three readings, three degradations. Recurrence needs nothing and always runs. Without published morphology the verb reading falls back to the lemma dictionary's infinitive ending, and without that dictionary too it is skipped. Without a name index the lore test is skipped. Nothing throws.

A parenthetical stage direction folds. The lore test resolves the whole segment, and a speaker column carrying a narrator's aside reaches nothing as a whole, even where its head names an entity:

[00:05] Nemera (już bliżej swojego legowiska): Hah, pan biolog się jak panienka nie folgował…

That line becomes narration instead of resolving to Bibliotekarka Nemera. Stripping a trailing parenthetical before the lookup would recover it, and that belongs to a pass that reads structure out of the speaker column.

The committed log archive (nerthus.logs/) — the one store

Which repository it is in

The archive is a repository of its own — repozytorium-transkryptow — and not part of the lore tree. Archiving a transcript is the transcript authority while editing the chronicle is rada, and the machine commit churn a fetch produces has no business in the campaign record.

A daemon finds it through corpora.logi, host-locally in .nerthus/local/corpora.json (the container writes that from NERTHUS_LOGI_URL). A host that has not been given one falls back to the nerthus.logs/ in its lore checkout, so an un-migrated host keeps serving the archive it already has. Everything below is about the tree; none of it changes with where the tree is.

A transcript's upstream host is not a durable home — the ct8.pl OAuth wall already killed over a thousand log links. So a fetched transcript is retained in the repo: nerthus.logs/ at the repo root holds, per transcript URL, the raw <name>.log text and its parsed <name>.json sidecar (filesystem-safe name derived from the URL). Raw text in the repo is what lets a future parser improvement re-parse every archived transcript offline — dead links included. Only .failed retry markers live in the gitignored .nerthus/cache/logs/; they are runtime state, not data.

  • Writing. Every fetch writes the archive pair directly — retention is not optional and there is no separate archive step. Fetching therefore mutates committed repo files: both fetch routes carry the Write flag and pass the write gate.
  • The URL → sidecar bridge. Every read resolves the session's @Logi URL to nerthus.logs/<name>.json — the archive is the only store consulted, so a served log's Source is always archive. Get-NerthusSessionLog and the traversal/door chains all take this path, so a fresh clone reads its transcripts fully offline. The committed archive is the sole transcript store, so cache/logs/ holds only .failed retry markers.
  • Fetching. A fetch that finds an archived sidecar reports Archived and makes no HTTP call. After an upstream correction, Invoke-NerthusSessionLogFetch -Force (body force: true) or the per-session -Refresh re-pulls past the archive.

The archive is generated output rewritten wholesale from fetch results — hand edits do not survive a re-fetch, unlike the hand-authored Polish sources (Architecture owns that distinction). The operator commits the archive like any other write — git review is the publication gate.


Discord channel delivery

The daemon delivers two kinds of message to players over Discord: PU notifications (from the monthly PU batch) and @Intel (session information targeted at an entity, delivered when the session is closed). It posts as a bot, resolving each player's channel from the @discord tag purposed gracz on their ## Osoby block in the committed contributor store — a kanał, kategoria name pair, not a URL. The chain runs: the entity's own @discord → the person's channel for that purpose → their unpurposed default slot → the ## Role block for that purpose → a recorded skip:

- @discord: gracz-eraster, gracze

A player may have @discord: BRAK (none); their PU is still computed and applied, but the send is skipped and recorded as SKIP in the delivery log. Operators read and set the tag over the API with Get-NerthusPlayerDiscordChannel / Set-NerthusPlayerDiscordChannel, where an empty channel clears it to BRAK. The channel name is committed lore, but posting to it needs the Discord bot token — a secret that lives host-local in .nerthus/local/discord.json ({ botToken, guildId }) or the CI env vars DISCORD_BOT_TOKEN / DISCORD_GUILD_ID, never in config.json (Configuration). The retired import extract local/secrets.json is never read or written, and a leftover file is inert.

Send-NerthusDiscordMessage

Delivery is a two-step bot call. First the daemon resolves the channel, category name to a channel id against the guild's channel list (GET /guilds/{id}/channels, cached on the daemon for channel_cache_ttl_min); a unique category+channel match wins, and zero or ambiguous matches skip with a reason rather than posting to the wrong place. Then it POSTs { "content": <message> } to /channels/{id}/messages as the bot — Authorization: Bot <token> — with a UTF-8 byte body (no double-encoding of Polish diacritics). The bot is write-only (View Channel + Send Messages, no Read Message History, no Message Content intent), so it never reads a player's channel. Every send — success, failure, or skip — is appended to the delivery log: an HTTP error records FAIL with its status code or error, an unreachable channel records SKIP with the reason (no_channel, no_token, channel_ambiguous, …). The record carries outcome metadata only — the resolved channel/category name, never the bot token and never the message text. A failed notification is therefore re-sent by re-composing the message from its source (the PU audit lines, or the @Intel intent), never replayed from the delivery log. -WhatIf returns a preview object and makes no request.

-Operation labels the delivery record for filtering: the default is message, the monthly PU batch records pu, and the close-time @Intel pass records Intel — so Get-NerthusDiscordDeliveryLog -Operation Intel can be diffed against Get-NerthusNotificationLog -Operation Intel to find sends that failed (see the examples below). A hand-driven re-send should pass -Operation Intel too, to stay in that diff.

The delivery log is .nerthus/log/deliveries.jsonl, append-only, one JSON record per line. A read filters by -Operation first, then sorts newest first and answers with at most 500 records. The file on disk keeps every one. A read also picks up .nerthus/state/discord-deliveries.json when that file exists; the daemon reads it and never writes it.

The monthly PU batch

Invoke-NerthusPUAssignment (see the PU model) groups its results per player and emits one combined message to that player's channel. The message is Polish, with one block per character, joined by a blank line:

Postać Lord Tussal: +5,00 PU (suma: 89,10, nadmiar: 0,60)

Decimal values in the message keep the on-disk comma form (0,15, 5,30). The nadmiar suffix appears when the overflow pool was touched (before or after the batch). Sends honor integrations.discord.enabled. Delivery is best-effort and non-fatal: a per-player send failure lands in the delivery log and the run continues to the remaining players — it does not roll back the PU writes (those already passed the fail-early gate atomically).

@Intel routing

@Intel is delivered when the Narrator closes the session (Invoke-NerthusSessionIntelService, called by Close-NerthusSession — see the sessions model). It is immediate by design: a character learns a thing at the table, not at month end, so intel never waits for the monthly sweep the way PU does. The pass is dedup-guarded by session header in .nerthus/state/intel-ledger.json, so re-closing a session notifies nobody twice.

Fan-out is by target type — one directive reaches as far as its target does (Resolve-NerthusIntelRecipients):

Target @typ Reaches
Postać / Gracz itself
Grupa every active Postać carrying it in @grupa
Lokacja every active Postać whose current @lokacja is it
NPC nobody — no player stands behind it, so the fact stays in the session for whoever plays the NPC next
anything else nobody

Retired (Usunięty) and inactive (Nieaktywny) characters are skipped. The message body is <session date>: <message>, so a player reads when their character learned it.

Each recipient then resolves to a channel by a fixed priority chain (implemented by Send-NerthusDiscordMessage -Entity <name>):

  1. The entity's own @discord tag (any entity type may carry one; BRAK counts as none).
  2. The owning Gracz's @discord tag (resolved via @należy_do).
  3. None — the target has no reachable channel; the send is recorded as SKIP.

Delivery is best-effort per recipient: a missing channel or a failed POST lands in the send list and the delivery log without blocking the other recipients, and without undoing the distribution that already succeeded. The header is marked delivered once the pass runs, so a failed send is re-sent by the operator-driven intent-vs-delivery workflow below, not by re-closing.


Cmdlet surface

  • Log & audit readsGet-NerthusLog, Get-NerthusChangeLog, Get-NerthusEntityHistory, Get-NerthusTransactionLedger, Get-NerthusNotificationLog
  • Game-log pipelineInvoke-NerthusSessionLogFetch, Get-NerthusSessionLog, Get-NerthusSessionLogParsed
  • DiscordSend-NerthusDiscordMessage, Get-NerthusDiscordDeliveryLog; the per-Gracz @discord tag is read and set with Get-NerthusPlayerDiscordChannel / Set-NerthusPlayerDiscordChannel

Routes, parameters, envelopes, and the Write-flag rule are in the API reference — of these routes only the discord-channel PUT is Write-flagged. The capability ids these routes demand (log.read, log.fetch, discord.send) are listed in the capabilities reference.


Examples

Everything that touched one character this month, from the audit stream:

(Get-NerthusEntityHistory -Name 'Lord Tussal').items |
  Where-Object { $_.ts -ge '2026-07-01' }

Fetch every transcript, then read one session's resolved location segments:

# Mass-fetch every session's transcripts into the committed archive, then read
# one session's parsed segments:
Invoke-NerthusSessionLogFetch                                   # no target = mass
$log = Get-NerthusSessionLog -Header '### 2026-07-01, Eraster rozmawia z Tussalem, Anward'
$log.Logs[0].Parsed.LocationSegments | Select-Object Raw, Resolved, StartLine, EndLine

# A single log behind an authenticated host (e.g. Cloudflare-gated krisaphalon):
Invoke-NerthusSessionLogFetch -Request @{
    url     = 'https://krisaphalon.ct8.pl/get/2026-07-01_Eraster_rozmawia_z_Tussalem_Anward'
    headers = @{ 'User-Agent' = 'Mozilla/5.0 …' }
    cookies = @{ cf_clearance = '<from a real browser session>' }
}

Find Intel whose send failed (a channel the bot could not reach, a player who had none at the time), then re-send it — labeled so the next diff sees it. Closing already delivered everything that could be delivered, so what this finds is the residue:

$intent = Get-NerthusNotificationLog -Operation Intel
$sent   = Get-NerthusDiscordDeliveryLog -Operation Intel
$undelivered = $intent | Where-Object {
    $t = $_.Target
    -not ($sent | Where-Object { $_.entity -eq $t -and $_.result -eq 'OK' })
}
foreach ($i in $undelivered) {
    Send-NerthusDiscordMessage -Entity $i.Target -Message $i.Message -Operation 'Intel'
}

Why it works this way

  • Log rotation — all four streams roll into a numbered segment (request.1.jsonl, request.2.jsonl, …) once the live file reaches 16 MB, so a tail walks a bounded file. Rolling archives rather than prunes: the segment index only climbs and no line is deleted, which is what lets audit.jsonl roll without ceasing to be the durable record. It is the stream that most needs the bound — these files live inside the lore volume, so one file that grew forever would grow every checkout of the corpus with it, and a retention rule is a decision an operator makes over segments rather than one the appender makes over lines. GET /logs/{stream} answers from the live file, reading its tail by seeking backwards from the end.
  • Transport — Discord bot channel posts are the only transport; Slack and email are out of scope (the no-plugin invariant).
  • Mass-fetch execution model — synchronous: the daemon is a single-consumer loopback server, so a full-corpus backfill blocks it for its duration. It is an occasional maintenance op, not a job.
  • Rich-template source of truth — the log-saver JSON record is parsed as authored; the parser never re-fetches or re-derives it from the rendered text.
  • Caller attribution — every audit line's actor is the resolved principal; request.jsonl carries none.
  • Automatic @Intel fan-out — fires on close, resolved by target type. The intent-vs-delivery diff is a repair tool for failed sends, not the delivery mechanism.
  • Intel is immediate, PU is monthly — intel goes out at close because a fact learned at the table should reach the player at once. PU waits because its cap is a per-month computation over many sessions, so a single session's award is not derivable in isolation.

See also