Adoption: the import model¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: none, retired with Core.
Adoption turns the hand-authored Nerthus lore repository (repozytorium-fabularne)
into a form the daemon can serve. It is one idempotent import — in-tree,
meaning it runs inside the live working copy. The single run walks the Polish
sources, emits the generated index nerthus.entities.md, moves the roster's free
narrator notes into the character files, and stamps the on-disk format version
directly. The same run recovers the PU award history and the Margonem map
catalogue from nerthus.maps.md, and reads legacy session shapes
as-is. A .nerthus/schema.json pointer
plus the write gate distinguish an adopted repo from an un-adopted one. For the
hands-on walkthrough, see the
Adopt a lore repository; this page explains the
model behind it.
Not adopted yet
The lore repository is not adopted. nerthus.entities.md exists on no branch, there is
no log archive, and the door graph is empty. The importer work described here precedes the
first real adoption, so the migration burden is zero: the layout below is built before any
lore is imported, never retrofitted onto a live index.
Concepts¶
| Term | Meaning |
|---|---|
| Import | The one canonical, idempotent, in-tree adoption run (Initialize-NerthusRepo → POST /import) |
| Generated index | nerthus.entities.md — the one lore file the importer regenerates |
| Roster seed | the ## Osoby section of nerthus.contributors.md — appended once, at adoption, when it is absent, and never touched again |
| PU dedup ledger | .nerthus/state/pu-ledger.json — every session already awarded PU, keyed by header |
| Format version | .nerthus/schema.json — a pointer recording the single index-format version the on-disk data satisfies (0.11.0) |
The cardinal rule: the importer never rewrites a hand-authored Polish source
file (the data-owner invariant — see Architecture). It reads
Gracze.md (the roster source on the bootstrap path), Postaci/**,
Organizacje/**, Świat gry/**, Wątki/**, and the two root data tables. It writes the
generated index, the derived Lokacje/ registry, and its
own state under .nerthus/ — every one of them generated output. One sanctioned
carve-out: import appends the roster's narrator-note bullets to their
Postaci/Gracze/** charfiles under **Dodatkowe informacje:** (the notes move,
below). The move is logged per bullet and never touches existing prose.
One pass, two paths¶
Initialize-NerthusRepo is the operator's adoption entry point; it runs the
importer inside the daemon via POST /import. The daemon's boot only scaffolds
the .nerthus/ layout (marker, config.json, Rada data tables) — it never
imports automatically. -WhatIf renders the would-be index without writing; a
real run writes it unconditionally and reports counts (entities emitted per
bucket, sessions observed, ledger sessions recovered). The same pass materializes
the Lokacje/ registry and returns its report as
SessionRegistry: the tree's auto-refresh seam only refreshes a tree that exists,
so adoption is where it comes into existence. There is no diff step —
the idempotency contract makes one unnecessary.
Idempotency is a hard contract — and the branch key is presence
The importer branches on what the index already carries — by presence,
never a schema pointer. On a repo whose index has no durable ## Postacie Graczy
section, import takes the bootstrap path: it builds the whole index
from the hand-authored sources (Gracze.md is the roster source), and
re-running on an unchanged repo rewrites the index to the exact same
bytes (UTF-8 no BOM). On a repo whose index already carries a durable
## Postacie Graczy section, import takes the preserve path: that durable
section and the index header are carried
verbatim from disk, and the machine sections (## NPC, ## Grupa,
## Lokacja, ## Mapa) are regenerated byte-identically and spliced in.
The bootstrap path needs a roster source. Import aborts with a structured
RosterSourceMissing error only when there is neither a ## Osoby
section in the index nor a Gracze.md file — never a silent empty roster.
Determinism comes from a fixed section order
(Gracze → Postacie Graczy → NPC → Grupa → Lokacja → Mapa), stable per-section
ordering (Gracze by nick; Postacie by owning player's nick, then roster order;
NPC and Grupa by name; Lokacja by hierarchy path; Mapa by numeric id), stable
serialization (canonical diacritic tags, fixed tag order), and first-seen
deduplication. A clean repo round-trips to itself.
Duplicate policy — first-seen wins, never silently¶
Entities key by their repo-relative source path, not by bare leaf name. Two locations that
share a leaf name in different directories — Świat gry/Torneg/Karczma/ and
Świat gry/Werbin/Karczma/ — are distinct entities, and the importer disambiguates them by
emitting qualified block names: ### Karczma (Torneg) and ### Karczma (Werbin), extending
the qualifier up the path until it is unique. A flat leaf key used to collapse roughly a quarter of
the location directories into one another; path keying ends that. @duplikat now marks only a
true duplicate — the same path imported twice, which should not happen.
Within one source walk, when two sources still yield the same keyed entity, the
first occurrence survives wholesale. The later record is dropped and its
source file is attached to the survivor as a @duplikat provenance tag
(semantics owned by the entity model). Each drop appends one
level: warn, comp: import record to .nerthus/log/operational.jsonl, carrying
the entity, type, and dropped sources. Two exceptions:
- A
Postaci/Gracze/**sheet whose name matches a roster character is that character's own file — an enrichment (@plik), never a dropped duplicate. - Mapa records key by
@margonemid, with the last catalogue snapshot winning.
Across the two passes, same-name Lokacja records union-merge instead — the deliberate exception documented under Pass 2. Same-header session bodies found in two files keep the first-seen body; the extra file is recorded on the session, without a warning.
The fixpoint merge¶
The import merges the previous index back in on every run (the per-block
fixpoint), so operator decisions — claim lines, hand-added tags, disk-only
blocks — survive regeneration (the merge contract is owned by
the entity model). On the preserve path that means the
machine sections only, since the durable ## Postacie Graczy section is never rebuilt.
After a real (non-preview) import the format pointer is stamped to the current
index-format version, 0.11.0.
The roster notes move¶
On the bootstrap path, each roster character's narrator-note bullets move from
Gracze.md into that character's Postaci/Gracze/<postać>.md, under the
existing **Dodatkowe informacje:** section (created before the first session
heading when absent). A player's own free bullets (- Inne: …) move the same
way, into the player's active character's file. Bullets already present are
skipped — the duplicate check is a normalized-substring match plus bigram
similarity ≥ 0.8. Every bullet appends one operational-log record
(action: note-moved, or note-duplicate when the bullet already exists
there). A note whose character cannot be resolved does not abort adoption:
it is logged action: note-unresolved and left verbatim in Gracze.md for the
operator (see the logging model). This is the one sanctioned
charfile append — previewable, per-bullet logged, and it never touches existing
prose. Deleting Gracze.md afterwards is the operator's own git commit; the
tool never writes to or deletes it, and git history preserves the archive.
Pass 1 — entity bootstrap → nerthus.entities.md¶
Pass 1 walks the human sources and emits five entity types — Gracz, Postać, NPC, Grupa, Lokacja. (Mapa comes from Pass 2; Przedmiot is never emitted by import.) On the preserve path the people output is discarded: Gracz and Postać come verbatim from the durable sections, and only the freshly built NPC and Grupa sections reach the file.
| Source (under repo root) | Emits | Key facts harvested |
|---|---|---|
Gracze.md (bootstrap path only) |
person (into ## Osoby in nerthus.contributors.md) + Postać (into the index) |
nick, ID Margonem → @margonemid, Tematy zastrzeżone → @tematy_zastrzeżone, plus @status: Aktywny and @rola: gracz on every seeded person; PU triple, aliases, owned-character links → @należy_do, bold link = active character → @status: Aktywny on the characters |
Postaci/Gracze/** |
Postać (sheet path only) | file path → @plik; /Martwi/ path segment → @status: Nieaktywny; sheet contents are never parsed by the importer — once a sheet is an @ref target, its leading property list is read at model build, a different pass (the entity model) |
Postaci/NPC/**/<miasto> |
NPC | town leaf directory → @lokacja (e.g. Postaci/NPC/Thuzal/…, Postaci/NPC/Ithan/…) |
Postaci/Elity, Elity 2, Kolosi, Tytani, Herosi/ (one literal directory name) |
NPC | tier leaf directory → @grupa |
Organizacje/{Graczy, NPCów}/** |
Grupa | one Grupa per org directory (Gildia Teologów, …); @plik = Plik Ogólny.md / Opis Ogólny.md / <Name>.md, else the first .md |
Świat gry/** |
Lokacja | one Lokacja per directory; the parent directory → @lokacja (folder nesting = hierarchy) |
The Świat gry/ walk emits the world skeleton — a dateless parent
@lokacja plus the directory @plik — which Pass 2 union-merges with its
session-derived enrichment into one ## Lokacja block per name.
The Gracze.md roster block format — and the Gracz/Postać model it feeds — is
owned by the roster model. Ownership (@należy_do) and the
PU triple come only from Gracze.md (the bootstrap path); character-sheet
files contribute nothing but their path. Boilerplate files (README.md,
Generyczny NPC, Ludność Krainy) are skipped. Each emitted record carries
@plik pointing back at its source file (/Postaci/Gracze/Eraster.md) — the
address session distribution routes text to (see
the session model).
Pass 2 — locations & maps → the ## Lokacja / ## Mapa sections¶
Pass 2 resolves names against three registries: the map registry, the
Świat gry/ tree, and the NPC containment tree. The map-registry source is
keyed on presence too: when the on-disk index already carries a ## Mapa
section, that section is the registry and is read back via
Read-NerthusIndexMaps (checkup-fresh @url; semantics owned by
the location model); otherwise the legacy snapshot below
bootstraps it. The other two registries are rebuilt by re-walking the
sources; both passes run inside the one index build. Two inputs:
-
maps.md(the bootstrap Margonem catalogue) — recovered fromnerthus.maps.md, a list of Margonem map records, one per line — semicolon afterId, comma beforeUrl:- Id: 4120; Nazwa: Rezydencja Tussal, Url: https://micc.garmory-cdn.cloud/obrazki/npc/rezydencja_tussal.0.png - Id: 4121; Nazwa: Rezydencja Tussal p.2, Url: https://micc.garmory-cdn.cloud/obrazki/npc/rezydencja_tussal_p2.0.pngEach record becomes a Mapa entity:
@margonemid,@url, optional@slug(the image filename),@alias(the stripped base name, when the floor/room/kopia suffix stripper changes it), and@lokacjawhen the base name matches aŚwiat gry/location. Records key by id; the last snapshot per id wins. (@wymiaryis never set by import — it arrives later via the maps API, see the location model.) -
Session
@Lokacjewalk — every session's@Lokacje(current) /*Lokalizacje:*(legacy) field is scanned acrossWątki/**and the org session files. Only interior places carrying an exterior parent are emitted; bare top-level mentions are already covered by Maps andŚwiat gry/. A real line,*Lokalizacje: Thuzal, Ithan, Thuzal/Rezydencja Tussal, Rezydencja Tussal/Ogród Tussala*, yields records forRezydencja Tussal(withinThuzal) andOgród Tussala(withinRezydencja Tussal), whileThuzalandIthanget no new record. Each emitted place carries@lokacjaparentage with an open-ended temporal scope from its first observed session month. The noisy raw set is deterministically normalized, and unrecognized prose is quarantined as@status: Niepewny; the normalizer and enrichment tags are owned by the location model.
Union-merge — the deliberate exception to first-seen dedup. A place named by
both walks yields one block. At emit, the Świat gry/ world record (dateless
parent @lokacja, directory @plik) and the session-derived enrichment
(temporal (YYYY-MM:) @lokacja/@outerior, @alias, and the
@forma_sesyjna claim lines, machine-appended ones marked (auto)) merge into
a single ## Lokacja block. This applies the same-name merge rule of
the entity model at emit time. Enrichment-only places and
quarantined (@status: Niepewny) blocks follow in the same section; the
## Mapa catalogue closes the index.
Reading legacy shapes — no corpus rewrite¶
The parser (Nerthus.SessionMetadataParser) reads all historical session
shapes (Gen1–Gen3 and the current @-tag form) directly, and the corpus stays
exactly as authored. The tool writes the current form only when it authors,
edits, or distributes a session. The generation table, detection rules, and the
malformed-header rescue (in-body @Data over a defective header date) are owned
by the session metadata reference. Two
import-specific notes survive
here. A - Zmiany: block is treated as a custom narrative tag — kept as prose,
never parsed; the change-directive was removed. And ASCII tag variants from old
edits (@nalezy_do, Usuniety) are accepted on read and normalized to
canonical diacritics in the generated index (the Polish-is-core rule — see the
tag schema).
PU history recovery¶
PU is computed monthly and the tool is authoritative going forward (see the PU model), but the historical baseline must be trusted, not recomputed — years of sessions were paid out by hand, and re-deriving them would diverge. Import seeds three things:
-
Baseline = authored totals. The roster PU line is imported verbatim into the
@pu_*tags — the parse rules (theZDOBYTEfallback, theBRAKsentinel, decimal handling) are owned by the roster model. The importer never re-adds old sessions to reach SUMA; SUMA as authored is the baseline. -
Dedup ledger ←
nerthus.pu-sessions.md. This dated file lists every session the legacy tooling already awarded PU for:The file is recovered into
.nerthus/state/pu-ledger.json: the dated assignment batches, plus a map of canonical session header → first award date. The monthly PU batch skips any session already in the ledger; the award date is kept because PU can be assigned late and election eligibility uses a dated rolling window (see the PU model).The same file also seeds the committed echo
nerthus.ledger.md, one- PU ### <header> (rozliczono <date>)line per settled session. Thepu-ledger.jsonhalf is machine-local, so without the echo a fresh clone — or a host adopted from a corpus whose state directory it does not carry — reads an absent file as nothing was ever settled and the drift guard passes everything. The seed is append-only and keyed on the header (OrdinalIgnoreCase): a header already echoed is skipped whoever wrote it, so a re-import is a no-op and an award's own line is never rewritten. A session the legacy file names but the corpus does not contain is echoed anyway — the ledger is the record of what was paid, not an inventory of what still exists. -
Org session files join by header key. Files like
Organizacje/NPCów/Gildia Teologów/Zlecenia.mdandOrganizacje/NPCów/Gildia Teologów/Sesje.mdhold session blocks authored outsideWątki/**. They are ingested by the same header key, so a session appearing in both files is one session. Combined with the ledger, a session contributes its@PUat most once.
The two data tables, and how Discord delivery lands¶
Import reads exactly two root-level tables: nerthus.maps.md (the Mapa registry
bootstrap, Pass 2 — read only when the index has no ## Mapa section yet) and
nerthus.pu-sessions.md (the PU ledger, above).
Both used to live under .robot/res/, the previous-generation tooling directory, which was
retired in August 2026. They moved to the repository root rather than into
.nerthus/data-tables/, and the reason is a published contract: the entity-ref resolver
checks PrivateDir — anything under .nerthus/ — before it checks ChokeFile, and both
refusal reasons are API. Filing the PU ledger under .nerthus/ would have changed what the
API says about it as a side effect of moving a file.
A repository that has not taken the move yet still imports: both readers fall back to
.robot/res/, and the PU ledger is a choke file under both names. The fallback goes once
the fleet and the CI test repository have been rebuilt — it is a fallback, not a second
home.
Discord delivery is a channel name, and adoption seeds none. The importer cannot
know a person's channel name, and guessing one would route a real person's private
notifications to the wrong place — so every seeded block ships without @discord,
and the Rada fills them in by hand afterwards, one - @discord: <kanał>, <kategoria>,
gracz line per player. The tool keeps the list of who still needs one, and a
channel-less person is simply not notified: delivery records a skip with a reason, never
an error. Posting to a resolved name needs the out-of-band Discord bot
token (host-local .nerthus/local/discord.json, or a CI secret), never a committed
URL. The block shape is the contributor store's. The retired .nerthus/local/secrets.json is never read or written — a leftover
file is inert, and the operator may delete it. The generated index stays at the repo
root so it is committed and human-readable; everything else lives under .nerthus/
per Architecture.
The adoption-window layout¶
The first import also lays down the multi-host layout the fleet needs, before any lore exists to import. Three pieces:
- Per-host echoes. Close-state and intel dedup are committed as per-host files,
nerthus.closed.<host>.mdandnerthus.intel.<host>.md, where<host>is the host slug. Each host appends header-keyed lines only to its own file; readers globnerthus.closed*.md/nerthus.intel*.mdand union them. Per-host files make cross-host echo-append conflicts impossible. Adoption emits the zero-state and never writes a single legacynerthus.closed.md. - The staging directory is ignored.
.nerthus/staging/joins the gitignore set (runtime/,local/,cache/,log/,state/,staging/) and the scaffold. A publisher assertion neargit add -Aaborts the publish if anything under.nerthus/staging/is staged; the session model owns why drafts stay host-local. - The index stays one file.
nerthus.entities.mdis not split per entity. Its machine sections regenerate wholesale on every import, andmerge=unionis forbidden on it and onnerthus.ledger.md— a union merge would silently double@ilośćrows while the header-keyed echoes block the corrective re-apply. A ratchet guard asserts no.gitattributesanywhere declaresmerge=unionfor either file.
The format version and the write gate¶
Distinct from the source walk, a single pointer records whether a repo has been adopted, and the write gate reads it. There is one index-format version — adoption stamps it in the same pass that writes the index.
.nerthus/schema.json— a pointer-only file recording the index-format version the on-disk data satisfies (absent ⇒0.0.0). The current version is0.11.0. A real import stamps it to0.11.0directly; the daemon expects0.11.0.SchemaTooOldmeans "not adopted yet." While the pointer is below the expected version, the write gate refuses every mutating route with403 SchemaTooOld— the remedy is to runInitialize-NerthusRepo(POST /import), which emits the index and stamps0.11.0, unlocking writes. The/importroute itself is control-plane recovery and stays exempt; the gate and the Write-flag rule are owned by the API reference, and the settle driver's dry-run gate surfaces exactly this state (settlement model).SchemaTooNewis the forward guard. A synced clone can pull an index stamped by a newer module than it runs (a future format bump). The gate refuses those writes with403 SchemaTooNew— update the module and restart — instead of writing against a format it does not understand. That forward guard is why an index-format version exists at all; the sync angle belongs to the sync design.- Preserve-on-re-import is keyed on presence, not on the pointer. The
durable
## Postacie Graczysection and the## Maparegistry are simply what the index carries after adoption; a re-import preserves them because they are present, not because any prior step ran. Idempotency comes from byte-identical rendering, exactly as above.
Cmdlet surface¶
Initialize-NerthusRepo (the whole adoption run) and Get-NerthusSchemaVersion
(the on-disk vs expected pointer).
Initialize-NerthusRepo issues exactly one HTTP call — POST /import; its
-WhatIf appends ?dryRun=true, previewing the index without writing. The
.nerthus/ layout is scaffolded by the daemon boot itself, not by a route.
Routes, parameters, envelopes, and capabilities:
API reference.
Adopt a fresh checkout, reviewing before committing anything:
Initialize-NerthusRepo -WhatIf # render nerthus.entities.md + the Lokacje/ plan, no writes
Initialize-NerthusRepo # write the index + Lokacje/ + move roster notes + recover the ledger; pointer -> 0.11.0
Get-NerthusSchemaVersion # on-disk vs expected (both 0.11.0 after adoption)
Adoption is one stage of setting up a repository — the log archive, the derived doors,
and the map sweep follow it. vm/Initialize-NerthusLoreRepo.ps1 runs all five stages in
order, propagates -WhatIf to each, and issues no Git command; the operator walkthrough
is Adopt a lore repository.
Re-run to confirm idempotency — a second import is a no-op:
Initialize-NerthusRepo ; Initialize-NerthusRepo # preserve path: machine sections identical, durable sections untouched
See also¶
- Architecture — the daemon,
.nerthus/layout, the write gate, non-goals - API reference — routes, envelopes, capabilities for the import surface
- Entity model — merging,
@duplikat, operator gestures, the fixpoint - Tag schema — the closed tag table, ASCII → diacritics
- Session model — the header key and distribution; legacy generations in the session metadata reference
- Roster model — Gracz/Postać, the retired
Gracze.mdseed format - PU model — PU accounting, the dedup ledger downstream
- Location model — the normalizer, Mapa entities, claim routing
- Configuration —
config.json, data tables, secret handling - Logging model — the
operational.jsonlstream - Adopt a lore repository — the step-by-step procedure, in the Hosting tab