Architecture¶
Nerthus.Core is a local REST daemon plus a thin PowerShell client for the
Nerthus lore repository. One daemon process owns all data; the PowerShell module and
every other consumer (the web dashboard, a future Discord bot) 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 explanation assumes.
Topology¶
flowchart LR
subgraph consumers["Consumers"]
C1["Nerthus.Core cmdlets"]
C2["Web dashboard (SPA + SSE)"]
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.ledger.md, nerthus.logs/"]
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).
- VM instance. A long-lived daemon on a VM serves its own clone, and its serve
loop also schedules git sync with origin — bot identity, publish modes, and the
SyncStalefreshness gate in Sync. - Public edge (optional). The VM daemon can be published at
evocation.nerthus.plbehind a Cloudflare Tunnel:cloudflaredco-locates on the VM and reaches the daemon over loopback, so the127.0.0.1bind is preserved literally while the edge owns TLS, CORS, and rate-limiting (Publish the API). - No live
Gracze.md. When the index carries no durable roster sections 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/ # Council-editable tables: currency.json, roles.json,
# towns.json (Werbin, Eder, Ithan, …) — seeded only when
# absent, so Council 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,
# discord-deliveries.json, governance/
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
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/
(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 — its ## Gracze +
## Postacie Graczy sections are hand-editable and never regenerated, while the
NPC/Grupa/Lokacja/Mapa sections are rebuilt from the lore sources — and its Polish
header comment states that split (Players & characters,
Adoption).
Note
Legacy .robot/ state is read once during import — only .robot/res/
(maps.md, pu-sessions.md) — to recover history. pu-sessions.md (the PU
ledger) is read on every import; 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. 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, and every session-bearing lore file. 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).
- 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 VM instance: 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 7-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 |
| 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 |
| VM deployment & sync | Sync | The VM instance: bot account + SSH key, the in-daemon git sync, publish modes, SyncStale |
| 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 |
| Configuration | Configuration reference | config.json, data tables, tunable constants |
Compiled C# substrate (lib/*.cs, namespace Nerthus)¶
Performance-critical pieces are C#, loaded once via Add-Type, called directly
(no dispatch indirection):
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.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).
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.
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.8.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.
See also¶
- Set up the repository · API reference · Configuration reference
- Glossary for every Polish domain term