Skip to content

The session model

A Sesja is one play session: a dated, narrated block of fiction plus the structured metadata the tool needs to award PU, move currency, and route notifications. Sessions are the core write target of Nerthus.Core — almost every mutation the daemon performs is anchored to a session block. This page defines the header key's role, authoring and editing, entity-driven distribution, and the integrity and participation machinery that keeps duplicated copies honest. For the plain-language introduction, start at the sessions overview; the exact grammar — header parsing, every metadata key, custom narrative tags, and the legacy generations — is the session metadata reference.

Concepts

  • Session header### YYYY-MM-DD, Title, Narrator: the universal key for deduplication, content-hash integrity, the participation graph, and distribution. Comparison is case-insensitive. The header is canonical and immutable — the tool never rewrites it; a malformed header date is rescued by an in-body @Data override while the key stays untouched (session metadata reference).
  • Narrator — the last header field, kept as a raw string. Narrator comparison uses the effective parsed narrator — the header value unless an in-body @Narrator override replaces it. The participation graph tracks participant entities only, so the narrator is matched by exact text.
  • Participant entity — any entity named in a session's structured metadata: the location(s) it took place in, the player-characters who earned PU, and (forward-designed) groups and NPCs. Distribution copies the block to each participant's file.
  • Current (Aktualny) body — the canonical @-tag session body the tool authors; the three older shapes parse as-is (legacy generations).
  • Session-bearing roots — the daemon scans *.md under exactly four directories: Wątki/, Organizacje/, Postaci/, Świat gry/. A session block anywhere else is invisible — to dedup, integrity, distribution, and the monthly PU batch alike.
  • @plik — the entity attribute naming an entity's hand-authored source file. Distribution resolves where a session block must be written by reading each participant's @plik (entity model).

The canonical example this page reuses:

### 2026-07-01, Eraster rozmawia z Tussalem, Anward

Eraster spotkał się z Tussalem w jego willi w Thuzal. Wypili herbatę i zjedli
obiad. ... (prose body) ...

- @Lokacje:
    - Thuzal
    - Thuzal/Rezydencja Tussal
- @Logi:
    - https://nerthus.pl/logizsesjierasteraztussalem
- @PU:
    - Eraster: 0,2
    - Lord Tussal: 0,2
- @Transfer:
    - 10 Korony, Eraster -> Lord Tussal
- @Uczestnicy:
    - Opat Perrin
- @Pliki: (auto)
    - Postaci/Gracze/Eraster.md
    - Postaci/Gracze/Lord Tussal.md
    - Świat gry/Thuzal/Sesje lokalne.md

Authoring & editing

Authoring (Add-NerthusSession; New-NerthusSession is a synonym) requires an explicit repo-relative -File along with -Date, -Title, and -Narrator. The daemon renders the block in the current @-tag form and inserts it at its chronological position in the target file — before the first later-dated session, else appended. Open-NerthusSession authors a draft (header + prose only) the same way.

Editing (Set-NerthusSession) splices only the managed sections the caller passes@Lokacje, @Logi, @PU, @Transfer, @Intel, @Uczestnicy. The mechanism: each managed section is the - @Tag: bullet plus its indented children, located by exact label; a passed section is replaced in place (appended at the end of the block when absent, removed when passed empty). Every other line — prose wherever it sits, custom narrative bullets like - Efekty:, unknown tags — is preserved byte for byte. The header, @Narrator, and @Data are not editable this way — the header is the key (see Concepts).

An edit rewrites only the session's primary copy — the first copy the scanner encounters (scan order: Wątki, Organizacje, Postaci, Świat gry), which is the model's authoritative block. Other distributed copies drift until the next distribution converges them.

Neither authoring nor editing distributes the block (see Distribution). Both honor -WhatIf dry-run previews, and both pass the write gate (Assert-NerthusWriteAllowed) like every mutating route — see the API reference.

Generic entity-driven distribution

A session is authored once — into one file under a session-bearing root — and distributed to the files of all participant entities. Distribution is generic and entity-driven:

  1. Extract participants from the block's structured metadata: characters from @PU and @Uczestnicy, plus the interior part of each @Lokacje reference (the text after the first /, or the whole token when there is none). So Thuzal/Rezydencja Tussal alone distributes to the Rezydencja Tussal entity, not to Thuzal — list the city standalone too (as the canonical example does) to reach its Sesje lokalne.md.
  2. Resolve each name → its entity → its file through the name resolver (fuzzy matching disabled), reading the entity's @plik. For the example: ErasterPostaci/Gracze/Eraster.md, ThuzalŚwiat gry/Thuzal/Sesje lokalne.md.
  3. Write the block into each target file at its chronological position, and record the full target set in the block's @Pliki: (auto) section.
  4. Dedup on the header — the source block is authoritative. The block is copied verbatim (prose and custom narrative tags preserved); only @Pliki is refreshed to the resolved target set. When the same header already exists in a target file the copy is replaced — every copy converges to the source content and re-distribution is idempotent.

@Pliki is machine-authored and rewritten whole on every distribution, so it carries the (auto) marker that says exactly that (tag schema). The section holds no value of its own, so the marker sits on the section line where a value would be. The section-matching regex is not end-anchored, so an unmarked @Pliki written before the marker existed is found and replaced in place — the next distribution upgrades it, and never doubles it.

Invariant

Distribution never invents a target. A name that does not resolve, an entity with no @plik, or a path resolving outside the repo root is skipped and reported in the response's Skipped list — the one signal for skipped targets. A Gracz carries no @plik, so a Gracz named in the metadata is always skipped (players model).

Distribution runs only when explicitly invokedInvoke-NerthusSessionDistribution, or as part of Close-NerthusSession. Adding or editing a session never auto-distributes; the AfterSessionDistributed hook fires after a distribution run (an audit line plus a session:distributed event). The settlement workflow re-distributes only sessions that already carry @Pliki and have drifted, never a session awaiting its first distribution (settlement model).

Closing: the two acts, and what each one is

The word "closing" covers one act only, and it is worth stating what it is not.

Committing a session to the repository is the Narrator's delivery: the block is final, the header is frozen as an identifier, and the work is handed over. It is a git operation and the tool has no part in it. A committed session that was never closed is inert — nothing is distributed, no coin moves, no player hears anything, and no monthly sweep will pick it up, because settlement only ever repairs what was distributed at least once.

Closing (Close-NerthusSession) is the explicit instruction that makes a session take effect. It applies, in order:

  1. distribution — the fan-out above, firing AfterSessionDistributed;
  2. @TransferInvoke-NerthusSessionTransfersService, dedup-guarded by header, firing AfterTransfer on first application (currency model);
  3. @IntelInvoke-NerthusSessionIntelService, dedup-guarded by header via state/intel-ledger.json (logs model §4);
  4. the hash re-baseline below.

Steps 2 and 3 are best-effort: each is wrapped, and a throw is warned and swallowed, because distribution has already landed and must not be reported as failed. Both are idempotent, so re-closing moves no coin and sends no message twice.

PU is the one exception, and the reason the monthly batch exists: the cap is computed per month across all of a character's sessions, so a single session's award is not derivable in isolation (PU model).

Closing spans capabilities its route does not name

POST /workflows/close-session requires session.write alone, yet closing now writes currency and sends Discord messages. This is deliberate: a Narrator's authority to move the coin comes from having run the session that records it, and the @Transfer line in their own committed block is the authorization. It is a real widening — before, only a workflow.settle caller (or the CI pipeline, behind an MR review) could enact a Narrator's transfer directives (settlement model, permissions).

Integrity — content hashing

Because one session lives in many files, the tool guards against silent edits with SHA-256 content hashing (Nerthus.ContentHasher):

  • The hash input is the full header line + body of the primary copy, with all whitespace stripped — reflows, blank-line churn, and CRLF/LF conversion never trip a false positive, while genuine content or PU changes always do. Values are 64-char lowercase hex.
  • Hashes live in one store file, .nerthus/cache/session-hashes.json, keyed by the header text without the ### prefix.
  • Test-NerthusSessionIntegrity compares current content to stored hashes and reports findings in three tiers:
    • CriticalPUSessionModified (a modified session carries @PU data) and DoublePUMarker (one block carries two @PU markers): PU tampering.
    • HighContentChanged (a modified non-PU session), Deleted (a hashed header no longer appears in the live model).
    • MediumUnhashed (no stored hash yet), MalformedDate (the effective date — header or @Data — is not a real calendar date), FutureDated.
  • Set-NerthusSessionHash re-baselines the entire store from the current session model — a global operation that clears every pending finding, not a per-session one. Close-NerthusSession ends with that same global re-baseline (see Closing above) — review Test-NerthusSessionIntegrity first if other pending drift matters.

Participation graph

The participation graph answers "which entities were involved in which sessions" with tiers of decreasing confidence, merged per session (lowest tier number wins):

Tier Signal Status
0 — Filesystem the block sits in the entity's file (matched through @plik) implemented
1 — Structured tags entity named in @PU, @Uczestnicy, or a @Transfer endpoint implemented
2 — Body mentions name found in the prose by the Polish-aware resolver deferred (needs calibrated mention confidence)

Tier-1 names run through the resolver (fuzzy matching disabled); an unresolved token is kept verbatim so the graph still surfaces it. The graph is computed on demand from the in-memory session model (Get-NerthusSessionGraph); Set-NerthusSessionGraph forces a model rebuild first, and Compare-NerthusSessionParticipation reports per-entity session sets plus their intersection over the same graph.

The per-entity and per-narrator profile lookups (Get-NerthusEntitySessionProfile, Get-NerthusNarratorSessionProfile) are simpler: exact-match scans over parsed metadata (@PU names, locations, @Uczestnicy; narrator string equality against the effective parsed narrator — the header value unless a @Narrator override replaces it). Find-NerthusSession's narrator filter compares the same effective value. They consult neither the graph nor the resolver — use the graph cmdlets when canonical resolution matters.

Cmdlet surface

  • Read / search: Get-NerthusSession (one session by header), Find-NerthusSession (filter by narrator, date range, entity), Get-NerthusSessionLog
  • Author / edit: Add-NerthusSession, New-NerthusSession, Set-NerthusSession, Open-NerthusSession, Close-NerthusSession
  • Distribution & transfers: Invoke-NerthusSessionDistribution, Invoke-NerthusSessionTransfers
  • Integrity: Test-NerthusSessionIntegrity, Set-NerthusSessionHash
  • Participation: Get-NerthusSessionGraph, Set-NerthusSessionGraph, Compare-NerthusSessionParticipation, Get-NerthusEntitySessionProfile, Get-NerthusNarratorSessionProfile

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

Examples

Author into an explicit file, then distribute:

Add-NerthusSession `
    -File 'Wątki/Intrygi w Thuzal.md' `
    -Date '2026-07-01' `
    -Title 'Eraster rozmawia z Tussalem' `
    -Narrator 'Anward' `
    -Locations 'Thuzal','Thuzal/Rezydencja Tussal' `
    -Logs 'https://nerthus.pl/logizsesjierasteraztussalem' `
    -PU @{ Eraster = '0,2'; 'Lord Tussal' = '0,2' } `
    -Transfers '10 Korony, Eraster -> Lord Tussal'

Invoke-NerthusSessionDistribution -Header '2026-07-01, Eraster rozmawia z Tussalem, Anward'
# → written to Postaci/Gracze/Eraster.md, Postaci/Gracze/Lord Tussal.md, and Świat gry/Thuzal/Sesje lokalne.md; @Pliki populated

Correct one section without disturbing the rest of the block:

Set-NerthusSession -Header '2026-07-01, Eraster rozmawia z Tussalem, Anward' `
    -PU @{ Eraster = '0,3'; 'Lord Tussal' = '0,2' }
# → only the - @PU: section is respliced; prose and '- Efekty:' stay byte-identical

Design decisions

  • Groups & NPCs in distribution — extraction stays explicit (via @Uczestnicy); auto-extracting mentions is deferred until Tier-2 mention confidence is calibrated. Decision: keep extraction explicit.
  • @Uczestnicy vs @PU@PU remains authoritative for character participation; @Uczestnicy records attendance without a grant; the two are unioned for distribution.
  • Multi-day end-date validation — the /DD · /MM-DD suffix is composed textually, never range-checked against the start. Decision: leave unvalidated until a real corpus case demands it.

See also