Architecture¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Nerthus.Core is a local REST daemon plus a thin PowerShell client for the
Nerthus lore repository. Among the Nerthus repositories it is the one that owns
the data contract and the API; the fleet that deploys it and the lore repository it manages have
pages of their own. One daemon process owns all data; the PowerShell
module and every other consumer (the web dashboard, the Discord integration) talk to it over
loopback HTTP. This is the Microsoft.Graph PowerShell model: small Verb-Nerthus* cmdlets,
each a thin wrapper over one REST call, no business logic client-side. This page is the keystone
every other contributor explanation assumes.
This page describes the frozen daemon
Nerthus.Core has been frozen since 2026-09-07: fix:, docs: and test: changes only,
archived after cutover. Everything below is a description of it as it stands and stays
accurate for it — it is not the design of what replaces it.
Nerthus.Platform keeps none of the
topology on this page: one process with composable roles rather than one daemon per
repository, PostgreSQL as a rebuildable view of git rather than in-memory indexes over
Markdown, a Rust core with a Python shell rather than PowerShell over a C# substrate, and
API v2 with no v1 shim. Core stays alive because Platform's parity harness diffs the two
field by field against reference.lock, which makes this page the specification under test.
See Contributors for where new work goes.
Topology¶
flowchart LR
subgraph consumers["Consumers"]
C1["Nerthus.Core cmdlets"]
C2["Web dashboard (static SPA, reads only)"]
C3["Discord / Margonem bot (future)"]
end
subgraph daemon["nerthusd (the daemon)"]
D1["Markdown parser + writer (C#)"]
D2["Name index (in-memory, self-heal)"]
D3["Entity / Session / PU / Currency / Location / Governance services"]
D4["Hooks, write gate, capability ACL"]
end
subgraph repo["repozytorium-fabularne (Git, Markdown)"]
R1["Postaci/, Wątki/, Świat gry/, Organizacje/, …"]
R2["nerthus.entities.md, nerthus.contributors.md, nerthus.ledger.md, …"]
R3[".nerthus/ (state, schema, runtime, logs)"]
end
consumers -->|"HTTP — Authorization: Bearer <token>"| daemon
daemon -->|"reads / writes"| repo
- Loopback only. The daemon binds
127.0.0.1; the port is OS-assigned by default, pinnable via-Porton the boot script or configserver.port. - One daemon per repo. Cmdlets discover it by walking up the directory tree to a
.nerthus/marker (git-style), then readingruntime/daemon.port+daemon.token. - Auto-spawn. The first cmdlet of a shell session spawns the daemon if its
discovery files are missing/stale, then waits up to 60 s for
GET /health. - Ephemeral CI instance. A scheduled pipeline may boot the same daemon on a CI runner's clone, drive it over loopback, and shut it down — one more single consumer, the same write gate and hooks. The two sanctioned uses are the settlement job (Settlement) and the map-checkup job (Set up pipelines).
- Fleet host. A long-lived daemon runs on a contributor device as a container pair:
nerthusdshares a network namespace withcloudflared, and the host answers at a per-town hostname (torneg.nerthus.pl) over an outbound-only named tunnel. The127.0.0.1bind is preserved literally: cloudflared reaches the daemon over loopback inside that shared namespace, so the edge owns TLS and rate-limiting while the daemon binds nothing public. The serve loop also schedules git sync with origin. The fleet model lives in Fleet, the sync behavior and freshness gates in Sync, and the edge bootstrap in Publish the API. - Sibling working trees. Beside the lore checkout the daemon may hold up to five
more working trees, one per sibling corpus — the moderation corpus (
corpora.mc), the works repository (corpora.dziel), the labelled-sample store (corpora.adnotacje), the transcript archive (corpora.logi) and the annotation tree (corpora.lang) — each read (and, for the archive alone, written) but never owned. Absence is tolerated everywhere: the moderator roster reads as empty and every moderation surface says so — present and unreachable, rather than a failed boot — and the two trees that lived inside the lore repository until the August 2026 splits fall back to their legacy in-lore paths on an un-migrated host (Configuration). - No live
Gracze.md. When the contributor store carries no## Osobysection yet, the import bootstraps them fromGracze.mdand moves its notes into the charfiles, retiring it as a source; deleting the file is the operator's own git commit, never the tool's (Players & characters, Adoption). - Lifecycle cmdlets:
Start-Nerthus,Stop-Nerthus,Get-NerthusStatus,Set-NerthusMode— routes, parameters, and envelopes in the API reference.
On-disk layout (.nerthus/)¶
Everything the tool owns lives under .nerthus/ at the repo root. The Polish lore
files outside it are the human-authored source; the tool reads them and writes only
the index files, its own state, and the sanctioned lore-file writes of the
data-owner contract below.
.nerthus/
schema.json # index-format version pointer the on-disk data satisfies
config.json # daemon + Nerthus config (committed; see the configuration reference)
data-tables/ # Rada-editable tables: currency.json, roles.json,
# towns.json (Werbin, Eder, Ithan, …) — seeded only when
# absent, so Rada edits survive boots
runtime/ # daemon.pid, daemon.port, daemon.token (0600), tokens/
# (named by SHA-256 id), jobs/ (persisted job records),
# sync.json (the sync tick stamp — see Sync)
state/ # private: pu-ledger.json, transfer-ledger.json,
# intel-ledger.json, identity-links.json,
# capability-grants.json, governance/,
# discord-deliveries.json (read-only)
cache/ # refetchable machine state (logs/*.failed retry markers,
# signing-key.pem, discord-jwks.json, session-hashes.json)
local/ # operator-managed host-local files: sync.json (git-sync
# enablement — see Sync) and secrets
log/ # operational.jsonl, request.jsonl, audit.jsonl,
# deliveries.jsonl
The scaffolded .nerthus/.gitignore covers runtime/ local/ cache/ log/ state/.
state/ is private (identity links, capability grants, governance, the PU
ledger) and is never committed to the lore repository. An existing .gitignore is
healed by appending missing lines at boot. Only schema.json, config.json, and
data-tables/ are committed. The name index and the model fingerprint are
in-memory only — there is no on-disk index, no response cache, and no lock file.
Generated and operator lore files live at the repo root, not under .nerthus/,
so they are committed and human-readable: nerthus.entities.md (the one unified,
hand-editable index — operator edits survive re-import via the per-block fixpoint
merge — plus any nerthus-*-ent.md overflow file)
(Entities & tags), the committed log archive nerthus.logs/
(in repozytorium-transkryptow, resolved through corpora.logi)
(Logs), and the committed ledger echo nerthus.ledger.md —
one appended line per PU-counted session and per applied @Transfer session. The
echo lets every clone see what has already been settled: when it lists an operation
the local private ledger does not know, the daemon refuses to re-settle it instead
of applying it twice (PU & elections,
Currency & economy).
After adoption the index is part durable, part machine-derived. A re-import rebuilds exactly
four sections from the lore sources — ## NPC, ## Grupa, ## Lokacja, ## Mapa — and
splices them into the file it read, so every other line survives verbatim: the hand-editable
## Postacie Graczy, and the ## Przedmiot and ## Region sections the daemon's own writes
create when the first entity of that type is added. The index's Polish header comment says the
file may be edited by hand and that the edits survive a re-import, and points at the
documentation for the rules (Players & characters,
Adoption).
Note
The two root data tables — nerthus.maps.md and nerthus.pu-sessions.md — are
read during import to recover history. nerthus.pu-sessions.md (the PU dedup
ledger) is read on every import; nerthus.maps.md bootstraps the ## Mapa
registry only when the index has none yet, since the index's own ## Mapa
section is the registry once present. Both lived under the retired .robot/res/
until August 2026, and the readers still fall back to that path for a repository
that has not taken the move. Like Gracze.md, retiring a legacy file is the
operator's own commit. See Adoption and
Locations & maps.
Request lifecycle¶
Every request flows through a fixed middleware chain over the closed route table (no runtime route registration):
Authenticate (401) → RouteMatch (404) → CapabilityCheck (403)
→ WriteGate / ReadOnly (403) → ContentType (415) → BeforeWrite (422) → dispatch
Authenticate precedes RouteMatch, and every route the table marks Write = $true
funnels through Assert-NerthusWriteAllowed — the single write gate. It refuses
with 403 SchemaTooOld in read-only mode or when the repo is not yet adopted,
403 SchemaTooNew when the on-disk index format exceeds the module, and
403 SyncStale when a sync-enabled instance has not converged with origin inside
its max age (Sync). Per-stage semantics, the public no-auth routes, the
Write-flag exemption list, envelopes, and status codes are owned by the
API reference.
Hooks are named internal functions, not a plugin registry:
BeforeWrite— fired by the dispatcher for everyWrite = $trueroute, after the write gate and body parse; a throw rejects the write with422 WriteRejected. Closed-schema validation (unknown tag/typ) happens in the handlers; this hook is the seam for cross-entity checks.AfterWrite/AfterSessionDistributed/AfterPuAwarded/AfterSync— append to the audit stream and publish the matching SSE event;AfterPuAwardedalso sends the best-effort per-player Discord PU message.AfterTransferandAfterGovernanceChangepublish their SSE events only — their audit lines (currency.transfer,governance.write) are written by the owning services. A throw here means the write already succeeded and surfaces as a warning only.AfterSessionDistributedis emitted by distribution itself (an explicit run or close-session), never by merely adding a session. Closing firesAfterTransfertoo, since it applies the session's directives; every caller of the transfer service fires that hook itself (Sessions).AfterSyncfires after every sync tick: a tick that published daemon writes to origin lands in the audit stream, a routine converge/no-op tick in the operational stream; both publishsync:completed(Sync).
The monthly PU batch runs as a synchronous in-daemon job (mass log fetch is a
plain synchronous maintenance op — Logs model); real-time events
are served as SSE on GET /events — contracts in the
API reference.
The data-owner contract¶
The daemon is the only thing that touches lore files. This is absolute:
- The client never reads entities, never parses Markdown, never computes PU.
- Fingerprint self-heal: before every dispatch — reads and writes alike —
the serve loop recomputes a timestamp fingerprint over all sources: the generated
index and overflow files,
schema.json, the PU ledger, every session-bearing lore file, and every resolved@reftarget from the previous model build (existing or not, so a target appearing or vanishing registers too). On any mismatch it rebuilds the entire in-memory model (entities, name index, session model, PU ledger). Hand-edits and git pulls are served without a restart; a fingerprint match costs only file stats. The serve loop waits for requests with a bounded one-second accept, and on a syncing instance runs the due sync tick between requests on the same dispatch thread (Sync). - Writes are surgical and format-preserving (UTF-8 no BOM, newline style retained);
soft-delete only (Entities & tags). One scoped exception:
the generated
Lokacje/registry physically prunes its own sentineledSesje.mdfiles when a place leaves the model — derived output, recoverable by regeneration, under the tested rule the session registry owns. - Exactly two sanctioned write classes touch a hand-authored charfile: session
distribution (Sessions) and the character-info writes into
its
**Dodatkowe informacje:**section (API reference). The import's one-time note-move writes the same section during adoption (Adoption). - One sanctioned git-mediated write class exists on a syncing fleet host: the sync tick's converge applies upstream commits to lore files and its publish commits and pushes accepted writes to origin — git is a substrate of the sync service only, nowhere else in the daemon (Sync).
Subsystem map¶
| Subsystem | Page | Responsibility |
|---|---|---|
| Entities & tags | Entities model | The eight-type entity model, Polish @tag schema, temporal values, hand edits + the @forma_sesyjna claim ledger |
| Sessions | Sessions model | Session records, the universal header key, entity-driven distribution |
| Players & characters | Players model | Gracz/Postać model, ownership, Margonem identity |
| PU & elections | PU model | Skill-point accounting, monthly batch, eligibility timeline |
| Currency & economy | Currency model | Korony/Talary/Kogi, transfers, reconciliation, Gini |
| Locations & maps | Locations model | Location hierarchy, Margonem maps, traversal graph |
| Name resolution | Name resolution | 4-stage Polish-aware resolver + the name index |
| Auth, identity & governance | Permissions | Margonem/Discord identity, tokens, capability ACL, Uprawnienia |
| Contributor store | Contributor store | The committed nerthus.contributors.md: functional-role rights, the personal-capability overlay, and role Discord channels |
| Adoption & import | Adoption | Ingesting the Polish repo (the one-pass import), the format-version pointer, ASCII→diacritic normalization |
| Settlement | Settlement model | The settle workflow (pending distribution/transfers/PU derived from committed state), the settle driver |
| GitLab pipelines | Set up pipelines | Scheduled job setup for settlement and the map checkup (schedules, variables, tokens), run testing and triage, late-settlement recovery |
| Fleet | Fleet | The GitOps fleet: hosts.yaml, roles, the converge agent, the health worker, budget doctrine, automatic failover, data residency |
| Sync & deployment | Sync | Converge-only replicas against the publishing primary, the tick pipeline, publish modes, the freshness gates, direct pushes, the post-converge lint |
| Logging & Discord | Logs model | Log streams, game-log fetch/parse, Discord delivery |
| REST API & cmdlets | API reference | The closed route table, cmdlet ⇄ route mapping, envelopes |
| The dashboard | The dashboard | Nerthus.Core.Dashboard: the static single-page app over the daemon's read surface, its routes and the API surface it consumes |
| Configuration | Configuration reference | config.json, data tables, tunable constants |
Compiled C# substrate (src/Nerthus.Core.Substrate/, namespace Nerthus)¶
Performance-critical pieces are C#, loaded once via Add-Type, called directly
(no dispatch indirection).
The sources live under src/Nerthus.Core.Substrate/, the one project in src/. They ship as an
assembly, not as sources: Initialize-NerthusCSharp (daemon/services/Common.ps1) calls
Add-Type on <ModuleRoot>/lib/Nerthus.Core.Substrate.dll. Nothing is compiled at daemon start.
lib/ is a build output directory. It is gitignored, holds no tracked file, and git does not
carry empty directories — so it does not exist at all in a fresh clone, and a contributor who
clones and runs the Pester suite without building first gets "Nerthus C# substrate not built".
That is a missing build, not missing sources. Two CI jobs own this:
build:substratecompiles the solution and publisheslib/Nerthus.Core.Substrate.dllas the artifact every daemon-booting job consumes. It exists so a syntax error in the substrate is one red pipeline on one branch rather than four hosts failing to boot at once.dotnet-testruns the xunit suites for both target frameworks.
The deployed fleet does not do this yet
Every host running today predates the change. /opt/nerthus/lib/ in the pinned image holds
14 .cs sources and no assembly — find /opt/nerthus -name '*.dll' returns nothing — so
those hosts still compile the substrate at boot from lib/*.cs, the arrangement this section
used to describe. The prebuilt assembly arrives with the next image roll, and whoever rolls it
should delete this box in the same change. Until then this page is true of main and of a
fresh checkout, and false of every running host — the condition to re-check is
find /opt/nerthus -name '*.dll' on a host, not a task number.
Both halves of that DLL path are load-bearing, and they fail differently. The wrong path and the
daemon refuses to start, naming the file. The wrong framework and it starts and then cannot find
a type: the project multi-targets, pwsh 7.5 is .NET 9, and a net10.0 asset handed to Add-Type
loads without throwing and registers nothing. net9.0 is the asset that ships. Building by hand:
dotnet build src/Nerthus.Core.Substrate/Nerthus.Core.Substrate.csproj -c Release
mkdir -p lib && cp src/Nerthus.Core.Substrate/bin/Release/net9.0/Nerthus.Core.Substrate.dll lib/
The types:
Nerthus.MarkdownScanner— single-pass Markdown line scanner.Nerthus.MarkdownEditor— the ONE format-preserving block editor shared by every write path: block location, plain and temporal tag-line upserts, new-block insertion, session insert/replace, and managed tag-section splicing.Nerthus.DeclensionEngine— Polish noun/adjective declension (suffix strip + consonant-alternation reversal).Nerthus.BKTree— Levenshtein BK-tree for fuzzy name matching.Nerthus.SessionMetadataParser— the one Nerthus session-body parser.Nerthus.LogParser— game-log content parser.Nerthus.SpeechIndex— the speech index's record build: one sidecar in, its indexed lines out. The channel gate and the scene-heading rule are mirrored from the daemon and held to it by a parity test; resolution and the session links stay daemon-side, because they are questions about the lore rather than about the file.Nerthus.AliasIndex— the alias harvest's archive pass. One sweep over the transcripts tallies, per lowercased token, how often it appeared capitalised, how often lowercase, which transcripts carried it, the surface spellings it wore, and which speaker followed a line that named it. The daemon's alias sieves are arithmetic over those tallies. The capitalisation counts are the load-bearing pair: a Polish common noun appears lowercase somewhere in a corpus this size and a name does not, which cuts the candidate space by more than twenty times without a stopword list.Nerthus.SearchIndex— the full-text index: the fold applied to every indexed surface form, the postings build, and theMatchescheck that decides whether an on-disk index still describes the corpus it was built from.Nerthus.ArchiveStamps— the per-sidecar stat walk that speech staleness is judged by, and the cache in front of it. It is a compiled static rather than a$script:variable for the same reason the search index is: a$script:variable belongs to one runspace, so every reader thread would otherwise walk the archive itself.Nerthus.EconomicAnalyzer— Gini / top-holder economic stats.Nerthus.ContentHasher— SHA-256 session integrity hashing.Nerthus.LocationNormalizer— mechanical location-normalization primitives (path split, fold keys, subdivision strip, prose detection).Nerthus.LogAppender— the serialized append behind every JSONL stream, plus the size-check-and-roll, inside one critical section per log path.File.AppendAllTextis open-seek-write-close, so concurrent writers interleave inside one another. Measured on this daemon at eight threads, a fifth of the lines never landed and some survivors were torn in half; nothing threw. That is what makes widening the reader pool safe (server.reader_threads, in the configuration reference): every reader thread records the request it served, and the lock is process-wide because a$script:variable belongs to one runspace while each reader thread has its own.
There is no PowerShell fallback shadowing these types; Add-Type is a hard
requirement. Naming and code-shape conventions live in the
code style reference.
src/ holds this one project, and tests/ShippedAssembly.Guard.Tests.ps1 is what keeps that
sentence true: it declares the assembly names the daemon may load and compares them against both
Dockerfiles and the CI build job, so a second project arriving unshipped goes red and names itself
rather than being read as running code.
Outbound HTTP¶
Every outbound request the daemon makes — the map checkup's version probes, the log
archive's transcript fetches, Discord delivery — goes through one pooled
HttpClient per redirect policy, held for the process lifetime
(daemon/services/Http.ps1). Invoke-WebRequest opens a fresh connection per call,
so over a sweep of thousands of probes the re-resolve, re-connect, and TLS handshake
cost dominates, and sockets accumulate in TIME_WAIT until requests start failing.
Measured against the Margonem CDN over 300 sequential HEAD probes, all three runs
returning the same statuses:
| Client | Wall clock | Failures |
|---|---|---|
Invoke-WebRequest |
827 s | 78 timed out at 10 s |
pooled HttpClient |
12 s | 0 |
| pooled, 8 concurrent | 1.7 s | 0 |
The failure column is the one that sets the cost. A transport error triggers the
caller's own retry and cool-down policy, so a self-inflicted timeout is charged at
retry_cooldown_ms, not at the price of the request. Two redirect policies exist
because the callers differ: a transcript may live behind a redirect, while a
map-checkup probe reads a redirect as an error, so a CDN front that starts 302-ing
unknown paths to a status page is never mistaken for a published version.
Deliberate non-goals¶
There is no plugin or campaign-agnostic machinery of any kind: no plugin discovery/manifests, no mechanic selector, no resolution-template registry, no locale/language plugin contract, no parser dispatch, no trust tiers. Multi-tenant REST hardening (filter grammars, rate limiting, idempotency caches, CORS negotiation, end-user token issuance UI) is likewise out of scope of the daemon — it stays loopback + single-consumer. When the API is published, that hardening lives at the Cloudflare edge in front of the tunnel, never in PowerShell (Publish the API). Nerthus's layout, types, and roles are fixed (as data tables, see the configuration reference).
Versioning¶
One index-format version (0.11.0), tracked by .nerthus/schema.json. The one-pass
import (Initialize-NerthusRepo → POST /import) stamps it directly, and the
write gate refuses mutations on a repo that is not adopted (SchemaTooOld, cleared
by running the import) or whose on-disk index format outruns the module
(SchemaTooNew). Detail in Adoption.
Two other version numbers exist and are not this one: the module's own version, which anonymous
GET /health discloses (discovery routes), and the pins that tie
a fleet consumer to the contract repository and to one daemon image
(Ecosystem overview).
See also¶
- Adopt a lore repository · API reference · Configuration reference
- Glossary for every Polish domain term