Skip to content

The session model

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

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, and read as the people in it. An evening several narrators adjudicated writes them in that one field, and the parse hands back both readings: the string as authored and the list of names. Narrator comparison uses the effective parsed narrator — the header value unless in-body @Narrator lines replace it. The participation graph tracks participant entities only, so the narrator is matched by exact text.
  • Reporter — the character who saved an evening's transcript, which is a different fact from who ran it. A council-adjudicated declaration is headed Rada, and for those evenings the reporter is the only person the record names (logs model derives it).
  • 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. The generated Lokacje/ tree at the repo root holds session copies outside those roots deliberately: it is output, never input (session registry).
  • @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).
  • Staged session — a session held as a host-local draft under .nerthus/staging/sessions/, invisible to the model until it lands at close. Enabled per host by sessions.staged (see Staging and the landing transaction).

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, which is the model's authoritative block. Scan order is deliberate and deterministic: roots in precedence order Świat gry, Wątki, Organizacje, Postaci — a location's own chronicle outranks the character-diary copies — and files ordinally sorted within each root, so the winner never depends on filesystem enumeration order. 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, as part of Close-NerthusSession, or when publishing an editing session whose edit touched a session's metadata: the publish re-derives the target set from the edited body (Invoke-NerthusSessionPropagation), writes an addition only when the target is named in -Create, and routes a publish that accepted a divergent target through a merge request instead of a direct push. 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).

Staging and the landing transaction

A session does not go straight into the lore files. When staged sessions are enabled (sessions.staged: true, a per-host pilot flag, default off), a session accumulates as one canonical staged draft, and closing lands the whole thing in a single atomic transaction. This is the model the rest of this page assumes. With the flag off the daemon writes sessions directly and closing enacts the same effects without the staging plane beneath it.

The staged draft

Each staged session is one file, .nerthus/staging/sessions/<sid>.md, holding a single session block keyed by its header; <sid> is a server-chosen slug. Staging sits outside the four scan roots, so the session model, PU, integrity, and distribution never see a draft — a staged session perturbs nothing until it lands. A registry, .nerthus/state/staging.json, records one entry per draft: { sid, header, ref, owner, lastWriteUtc, lastSnapshotUtc }. Each staged write appends an audit line and publishes the event session:staged, carrying the header, the <sid> and the action, never the content.

Drafts are durable. After each staged write the daemon pushes the draft as a single-commit ref, refs/heads/staging/<sid>, to origin — a write-ahead log, never a merge branch. If the disk survives, the host resumes from it on boot; if the disk is lost, the last snapshot is on origin, and any town host can adopt a staging/<sid> ref. Two hosts writing the same <sid> is last-writer-wins, never a merge: the handoff is to adopt the ref, not to co-edit. The host that loses the ref says so, in a warning naming both hosts and in the event staging:overwritten.

The owner reads their own drafts back through the ordinary session routes — GET /sessions/{header} and the close-impact dry run overlay the owner's staged content on the pure model — but a draft never leaks into fan-out, PU, or integrity. GET /sessions/staged lists the local drafts plus a fleet-wide existence tier: the headers of every origin staging/* ref, content invisible until close. Deferred content visibility is the honest cost of staging, and the staging-age nag below keeps it from turning into rot.

Closing is the atomic landing

Closing (POST /workflows/close-session, or /zamknij-sesję from Discord) runs one transaction on the closing host's dispatch thread, and it either lands whole or changes nothing:

  1. Converge fresh main — fetch and fast-forward. Genuine divergence refuses to a runbook, because under staging the daemon holds no unpushed session commits, so a diverged clone is real trouble.
  2. Already-closed check against the fresh closed-echo union — a cross-host no-op if another host already landed this header. Racing closers arbitrate by origin push atomicity: the first to land wins, the loser no-ops on retry.
  3. The semantic gate (below). A refusal returns findings in-band, writes nothing to the lore, and leaves the draft intact.
  4. Materialize against the converged tree: upsert the canonical block into the narrator's chosen file; run the entity-driven distribution above; and recompute @Transfer from the session's directives against fresh balances, folding each delta into a dated @ilość. Transfers are recomputed, never replayed — replaying the previously materialized absolute values would silently revert any balance change that landed in between.
  5. One commit, message Zamknięcie sesji: <header>, author-stamped from the authenticated enactor, asserting nothing under .nerthus/staging/ is included.
  6. Push inside a compare-and-set loop: on a non-fast-forward reject, git reset --hard origin/main and retry from step 1, up to a bounded number of attempts (default 5, 60-second git timeouts). At one to three closes a day the loop lands on the first or second attempt, absorbing any human push that interleaves.
  7. Finalize, only after the push succeeds: send @Intel (checked against the intel-echo union first), append the closed and intel echoes to this host's echo files, persist the transfer and close dedup ledgers, and publish session:landed. Committed state is always at least as far along as the side effects — a failed push leaves no intel, no ledger row, no echo.
  8. Cleanup — delete the staging file and its staging/<sid> ref; the registry deregisters.

Because the dedup ledgers are written only after the push, a retry re-materializes cleanly rather than tripping its own half-finished state. That ordering is what makes the compare-and-set loop safe.

The semantic gate and its findings

Over the candidate tree, at every attempt, the gate runs seven checks and reports each failure as a finding of one named kind. Every kind carries a disposition, and the worst disposition among the findings is the verdict:

Kind Default What trips it
name-ambiguous refuse a fan-out target resolves to more than one entity
name-miss escalate a fan-out target resolves to nothing
chimera refuse the same entity heading appears twice within one declared type, counted across the index and its overflow files
choke-file refuse the block touches a settlement-owned file, Gracze.md or nerthus.pu-sessions.md
header-changed refuse an already-echoed session's header line is gone from fresh main
dangling-claim escalate a @forma_sesyjna claim, (auto) ones included, matches no session record
claim-conflict escalate one claimed form is assigned to several blocks, so its mentions go to quarantine
claim-on-deleted escalate a claim sits on a block whose @status is Usunięty
integrity escalate another session carries a Critical or High integrity finding (see Integrity below)
copies-diverged escalate the landing session's own copies disagree across files — a write that picks one copy silently discards the others

Fan-out names resolve with fuzzy matching off, which is why the qualified location namespace matters. A name shared by a Lokacja and the Mapa depicting it addresses two distinct blocks, so it is not a chimera. The landing session's own header is exempt from the integrity check, because it is mid-write. Findings are capped at 500, and a check that fails internally is dropped rather than counted, so one broken check cannot deny every landing.

A refuse is a hard 4xx: nothing is written and the narrator fixes the draft. An escalate pushes the staged content as branch sesja/<stamp>, opens a merge request titled Sesja: <header> with the findings in its description, and publishes session:escalated; the Rada resolves it from there. Exhausting the compare-and-set attempts escalates the same way.

The mapping is config-visible one kind at a time: sessions.gate.taxonomy sets a named kind to refuse or escalate and leaves the rest at their defaults (Configuration), so the Rada tunes close-time noise without a code change. A kind the daemon does not know escalates. The taxonomy holds eleven keys; the last, materialize-error, no check emits: a materialize failure escalates from the materialize step itself instead.

The copies-diverged check is scoped deliberately to the session being landed: the narrator can answer for the one session they are landing, while corpus-wide divergence — hundreds of headers on the production corpus — belongs on GET /sessions/divergence, not on every close.

Closing has its own capability, session.close

POST /workflows/close-session requires session.close, not session.write — closing enacts, so it is gated apart from authoring. The narrator and radny bundles carry both. A Narrator's authority to move the coin comes from having run the session that records it; the dedicated capability just makes that enactment power grantable on its own (settlement model, permissions).

Why closing is its own act, not a side effect of the commit

The split looks like ceremony until you notice that each of a close's effects is something a git commit can neither perform nor take back:

  • The effects have no inverse. @Intel is a real outbound Discord bot POST; @Transfer appends dated rows to nerthus.ledger.md — grep the code for a revert or a refund and you find none. Both dedup by header, so the first enact seals the values and a corrected re-close silently no-ops. A commit is reversible (revert, amend, force-push); enacting a work-in-progress block would lock the wrong numbers in permanently. This is why even the Rada gets a deliberate act rather than an auto-close: the effect is unrecallable, so the moment of enactment is a decision, not a byproduct.
  • The moment is the tamper-evidence baseline. Materialize re-baselines the content hash (Integrity, below); a bare commit does not. A session closed at placeholder-plus-PU can have its prose backfilled afterward, and that backfill is an authorized amendment, not tampering — POST /sessions/{header}/rebaseline re-baselines that one session's hash so the later edit does not read as drift.
  • It answers to the write gate; a commit does not. Closing routes through Assert-NerthusWriteAllowed, so it is refused on a schema-drifted, read-only, or sync-stale clone — exactly the states the tool must not enact from. A commit can be made in any of them.
  • It is previewable. The whole fan-out sits behind a dry run (-WhatIf / ?dryRun=true): the coin moves, the asymmetry warnings, and the intel recipients are all inspectable read-only before anything is enacted (Get-NerthusSessionCloseImpact renders exactly that preview). A commit is fire-and-forget.
  • It carries the enactor's identity. On a shared fleet host the git author is the generic host identity, so "who closed this" comes from the authenticated call (gracz:<margonemId>), never from the commit.

Tracking what still needs closing, and the nag

A committed-but-unclosed session — or a staged draft never landed — is inert, so the tool tracks close-state explicitly rather than inferring it from a block tag. Close-state lives in a machine-local state/close-ledger.json (fast dedup on the serving clone) plus committed per-host echoes: each host appends header-keyed lines only to its own nerthus.closed.<host>.md and nerthus.intel.<host>.md, and readers glob nerthus.closed*.md / nerthus.intel*.md and union them. Per-host files make cross-host echo-append conflicts impossible, and the union is what bounds intel to at-most-once per header across the whole fleet. The echoes stay separate from nerthus.ledger.md, so a plain close does not trip the settlement-review MR, and a CI clone with no private state still knows what is closed. A session needs closing when it is neither recorded-closed nor already distributed. Get-NerthusOpenSession (GET /sessions/open) lists them; the staged tier surfaces there and in GET /sessions/staged.

Open sessions are nudged to Discord: each to the channel of the Narrator who ran it and to the shared Rada channel; a session whose narrator does not resolve to a player is the Rada's, and goes to the Rada channel only. The narrator resolves to a player through the name engine — narrators sign with their active character name or their player name, both fold to the same person — and channels are keyed by that player's Margonem id, so a rename never strands one. The reminder carries the Polish close-impact preview, and a Narrator closes from Discord with /zamknij-sesję (optionally a date, a title fragment, or the exact header; the "Zamknij sesję" message command reads the header out of a pasted block).

One deployment half runs that nudge, never both: schedules.close_reminder.owner names either the primary's tick or the GitLab schedule (Configuration). A grace period is available for narrators who close the same evening — first_reminder_delay_hours counts from the session's own date, and defaults to nudging on the next pass.

A separate staging-age nag runs as a primary tick against origin's staging/* refs: it warns at seven days and escalates at around twenty-five, with a per-header cooldown. Its job is to protect PU and fight rot, not to prevent conflicts — a narrator should close at placeholder-plus-PU within days and backfill prose later. A landing past the robot's ±30-day discovery window is repaired by the settle-past-month path: the PU is late, never lost.

Write forwarding, retired

An earlier design forwarded interactive writes to a single writer host. It is retired as the shipping write mechanism and shelved intact: session writes are host-local staging plus origin-serialized close, and non-session hand edits stay plain direct pushes. It revives only if interactive non-session write demand reappears, or if three or more active write-hosting classes emerge.

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, and three arms project 116 added for populations the parser had always carried and nothing published: NarratorAbsent (the header has no narrator field — 92 sessions), NarratorDropped (it has one and the grammar refused it, because the title contained a comma; the row carries the refused text — 10), and LogsUnparsed (@Logi holds a value that yielded no URL and is not an authored "brak" — 7). None of the three can be repaired by the daemon: the header is the deduplication key and the PU ledger's key. The repair for the first two is a - @Narrator: line, which the tag path accepts without the name test precisely so an operator can overrule the grammar. Counts measured on b46b0da0.
  • The walk also keeps a hash per copy, and GET /sessions/divergence (session.read, replica-safe) reads them: it classifies every header's copies as diverged beyond whitespace or in bytes only, always publishes both totals, and computes the @PU facet (puDiffers/puUnresolved/puParseUnstable, the last of which reads 0 since project 116 fixed the child-indent rule that caused all four of its cases, and is kept at 0 as a regression test) — contract in the API reference.
  • Set-NerthusSessionHash re-baselines the entire store from the current session model — a global operation that clears every pending finding. Landing a close re-baselines as part of materialize. POST /sessions/{header}/rebaseline re-baselines exactly one session, for an authorized post-close prose backfill — 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 / stage: Add-NerthusSession, New-NerthusSession, Set-NerthusSession, Open-NerthusSession, Close-NerthusSession, Get-NerthusStagedSession (GET /sessions/staged)
  • 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 takes one optional parameter per managed section; the call below omits -Prose and -Participants (@Uczestnicy) — the fiction body and the Opat Perrin the canonical block above carries — so add those two to author the whole block in one call:

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

Why it works this way

  • Groups & NPCs in distribution — extraction stays explicit, via @Uczestnicy; a name found only in the prose is never auto-extracted for distribution.
  • @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 and is not range-checked against the start date.

See also