Skip to content

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 -Port on the boot script or config server.port.
  • One daemon per repo. Cmdlets discover it by walking up the directory tree to a .nerthus/ marker (git-style), then reading runtime/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: nerthusd shares a network namespace with cloudflared, and the host answers at a per-town hostname (torneg.nerthus.pl) over an outbound-only named tunnel. The 127.0.0.1 bind 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 ## Osoby section yet, the import bootstraps them from Gracze.md and 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 every Write = $true route, after the write gate and body parse; a throw rejects the write with 422 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; AfterPuAwarded also sends the best-effort per-player Discord PU message. AfterTransfer and AfterGovernanceChange publish 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. AfterSessionDistributed is emitted by distribution itself (an explicit run or close-session), never by merely adding a session. Closing fires AfterTransfer too, since it applies the session's directives; every caller of the transfer service fires that hook itself (Sessions). AfterSync fires 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 publish sync: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 @ref target 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 sentineled Sesje.md files 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:substrate compiles the solution and publishes lib/Nerthus.Core.Substrate.dll as 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-test runs 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 assemblyfind /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 the Matches check 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.AppendAllText is 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-NerthusRepoPOST /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