Skip to content

Logs, audit, and Discord delivery

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 webhook delivery posts PU and @Intel notifications to each player's personal channel.

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/ at the repo root — the ONE committed transcript store (raw + sidecar per URL), readable offline in every clone
.failed marker A file in the gitignored cache/logs/ recording that a transcript URL could not be fetched, to suppress redundant retries
PRFWebhook The per-Gracz Discord webhook URL — the @prfwebhook tag on the Gracz block in the committed index — pointing at that player's private channel
@Intel A session directive that targets an entity with a private message routed to its webhook
Delivery log The state file recording every actual Discord send outcome

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 three separate JSONL streams. Each line is one self-contained JSON object; the files are append-only, readable with any tail. No stream is rotated today (see the design decisions below).

.nerthus/log/
  operational.jsonl   # daemon lifecycle + import warnings
  request.jsonl       # one line per HTTP request (method, route, status, ms)
  audit.jsonl         # one line per state mutation — never rotated (source of truth)

None of the streams ever carries a bearer token, a webhook URL, 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; the audit trail is audit.jsonl below.

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

audit.jsonl — durable source of truth

Every state mutation appends audit lines: entity/session/currency writes emit theirs via the AfterWrite-family hooks, while the PU batch, @Transfer applies, and ACL/identity/governance changes emit theirs directly from their services. The file is never rotated, never truncated, never rewritten; 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), 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 state file: 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).
  • 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 (e.g. under Logi/). 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.

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. krisaphalon /get/* URLs are fetched as-is.
  • 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.
  • .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.

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 [HH:MM] lines select this format. Lines parse into Time, Channel (Lokalny/Prywatny/Grupowy/Szept), 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.
  • Prose (basic) — hand-written narrative summaries (the fallback). Location headers are short lines (≤60 chars) after a blank line with no Speaker: pattern; every other line is kept whole as Text with no Speaker extracted.
Ogród Tussala

Narrator: Popołudnie było ciepłe i spokojne.
Eraster: Burmistrz Czemkin to spore utrapienie.
  • 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; none → 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, plus the rich-only Author, Command, Receiver, 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 (ChatLog and RichJson lines both do; Prose lines carry none): each distinct in-game nick is resolved once, unfiltered by type (the index priority already prefers Postać over Gracz); each line carries SpeakerResolved and SpeakerType, null for an unknown nick. This makes "which logs does this Postać actually speak in" answerable off the parsed line stream.

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.

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

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 webhook delivery

The daemon delivers two kinds of message to players over Discord webhooks: PU notifications (from the monthly PU batch) and @Intel (session information targeted at an entity, delivered when the session is closed). Each player's webhook is the @prfwebhook tag on their Gracz block in the committed index — the one and only webhook source:

- @prfwebhook: https://discord.com/api/webhooks/1211971813370892408/-7i1JynZrz…

A player may have @prfwebhook: 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-NerthusPlayerWebhook / Set-NerthusPlayerWebhook, where an empty PUT value clears it to BRAK. Committing the URLs is an owner decision recorded in the secrets policy (Configuration); the retired import extract local/secrets.json is never read or written, and a leftover file is inert.

Send-NerthusDiscordMessage

The daemon posts { "content": <message>, "username": <bot name> } to a webhook as a UTF-8 byte body (no double-encoding of Polish diacritics); the bot display name is overridable per send (default Bothen). Every send — success, failure, or skip — is appended to the delivery log: an HTTP error records FAIL with its status code or error, a missing webhook records SKIP. The record carries outcome metadata only — never the webhook URL 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 monthly PU batch

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

Postać "Lord Tussal" (Gracz "Tussal") otrzymuje 5,00 PU.
Aktualna suma PU tej Postaci: 89,10, PU nadmiarowe: 0,60

Decimal values in the message keep the on-disk comma form (0,15, 5,30). The PU nadmiarowe 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 webhook by a fixed priority chain (implemented by Send-NerthusDiscordMessage -Entity <name>):

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

Delivery is best-effort per recipient: a missing webhook 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 webhook tag is read and set with Get-NerthusPlayerWebhook / Set-NerthusPlayerWebhook

Routes, parameters, envelopes, and the Write-flag rule are in the API reference — of these routes only the webhook 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 webhook that was down, 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'
}

Design decisions

  • Log rotation — no stream is rotated today; audit.jsonl never will be. If size ever matters, archive old lines, never delete.
  • Transport — Discord webhooks are the only channel; Slack/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 deliberately carries none.
  • Automatic @Intel fan-out — fires on close, resolved by target type. It was deferred for a long time, and the intent-vs-delivery diff is what remains of that period: it is now a repair tool for failed sends, not the delivery mechanism.
  • Intel is immediate, PU is monthly — the asymmetry is not an oversight. PU waits because its cap is a per-month computation over many sessions; intel has no such coupling, so making a player wait a month to learn what their character heard at the table would be a bug, not a policy.

See also