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@Dataoverride 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
@Narratorlines 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
*.mdunder 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 generatedLokacje/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 bysessions.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:
- Extract participants from the block's structured metadata: characters
from
@PUand@Uczestnicy, plus the interior part of each@Lokacjereference (the text after the first/, or the whole token when there is none). SoThuzal/Rezydencja Tussalalone distributes to the Rezydencja Tussal entity, not to Thuzal — list the city standalone too (as the canonical example does) to reach itsSesje lokalne.md. - Resolve each name → its entity → its file through the
name resolver (fuzzy matching disabled), reading
the entity's
@plik. For the example:Eraster⇒Postaci/Gracze/Eraster.md,Thuzal⇒Świat gry/Thuzal/Sesje lokalne.md. - Write the block into each target file at its chronological position,
and record the full target set in the block's
@Pliki: (auto)section. - Dedup on the header — the source block is authoritative. The block is
copied verbatim (prose and custom narrative tags preserved); only
@Plikiis 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 invoked —
Invoke-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:
- 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. - 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.
- The semantic gate (below). A refusal returns findings in-band, writes nothing to the lore, and leaves the draft intact.
- Materialize against the converged tree: upsert the canonical block into the narrator's chosen
file; run the entity-driven distribution above; and recompute
@Transferfrom 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. - One commit, message
Zamknięcie sesji: <header>, author-stamped from the authenticated enactor, asserting nothing under.nerthus/staging/is included. - Push inside a compare-and-set loop: on a non-fast-forward reject,
git reset --hard origin/mainand 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. - 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 publishsession: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. - 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.
@Intelis a real outbound Discord bot POST;@Transferappends dated rows tonerthus.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}/rebaselinere-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-NerthusSessionCloseImpactrenders 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-NerthusSessionIntegritycompares current content to stored hashes and reports findings in three tiers:- Critical —
PUSessionModified(a modified session carries@PUdata) andDoublePUMarker(one block carries two@PUmarkers): PU tampering. - High —
ContentChanged(a modified non-PU session),Deleted(a hashed header no longer appears in the live model). - Medium —
Unhashed(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), andLogsUnparsed(@Logiholds 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 onb46b0da0.
- Critical —
- 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@PUfacet (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-NerthusSessionHashre-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}/rebaselinere-baselines exactly one session, for an authorized post-close prose backfill — reviewTest-NerthusSessionIntegrityfirst 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. @Uczestnicyvs@PU—@PUremains authoritative for character participation;@Uczestnicyrecords attendance without a grant; the two are unioned for distribution.- Multi-day end-date validation — the
/DD·/MM-DDsuffix is composed textually and is not range-checked against the start date.
See also¶
- Sessions overview — the operator introduction to this topic
- Record a session — the step-by-step recipe
- Session metadata reference — exact grammar per key
- Architecture — the daemon/client contract every page assumes
- API reference — routes, cmdlet mapping, envelopes, capabilities
- Entity model —
@plik, entity tags, the eight types - PU model — how
@PUbecomes the monthly batch - Name resolution — participant and mention resolution
- Players model — Gracz/Postać participant model
- Locations model —
@Lokacjepaths and->routes - Logs model —
@Logifetch/parse,@Inteldelivery - Currency model —
@Transferand the apply-transfers workflow - Adoption — how the import reads the legacy shapes as-is