Skip to content

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-NerthusRepoPOST /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:

  1. maps.md (the bootstrap Margonem catalogue) — recovered from nerthus.maps.md, a list of Margonem map records, one per line — semicolon after Id, comma before Url:

    - 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.png
    

    Each 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 @lokacja when the base name matches a Świat gry/ location. Records key by id; the last snapshot per id wins. (@wymiary is never set by import — it arrives later via the maps API, see the location model.)

  2. Session @Lokacje walk — every session's @Lokacje (current) / *Lokalizacje:* (legacy) field is scanned across Wą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 for Rezydencja Tussal (within Thuzal) and Ogród Tussala (within Rezydencja Tussal), while Thuzal and Ithan get no new record. Each emitted place carries @lokacja parentage 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:

  1. Baseline = authored totals. The roster PU line is imported verbatim into the @pu_* tags — the parse rules (the ZDOBYTE fallback, the BRAK sentinel, decimal handling) are owned by the roster model. The importer never re-adds old sessions to reach SUMA; SUMA as authored is the baseline.

  2. Dedup ledger ← nerthus.pu-sessions.md. This dated file lists every session the legacy tooling already awarded PU for:

    ## Historia
    - 2026-08-01 03:16 (UTC+02:00):
        - ### 2026-07-01, Eraster rozmawia z Tussalem, Anward
    

    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. The pu-ledger.json half 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.

  3. Org session files join by header key. Files like Organizacje/NPCów/Gildia Teologów/Zlecenia.md and Organizacje/NPCów/Gildia Teologów/Sesje.md hold session blocks authored outside Wą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 @PU at 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>.md and nerthus.intel.<host>.md, where <host> is the host slug. Each host appends header-keyed lines only to its own file; readers glob nerthus.closed*.md / nerthus.intel*.md and union them. Per-host files make cross-host echo-append conflicts impossible. Adoption emits the zero-state and never writes a single legacy nerthus.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 near git add -A aborts 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.md is not split per entity. Its machine sections regenerate wholesale on every import, and merge=union is forbidden on it and on nerthus.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 .gitattributes anywhere declares merge=union for 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 is 0.11.0. A real import stamps it to 0.11.0 directly; the daemon expects 0.11.0.
  • SchemaTooOld means "not adopted yet." While the pointer is below the expected version, the write gate refuses every mutating route with 403 SchemaTooOld — the remedy is to run Initialize-NerthusRepo (POST /import), which emits the index and stamps 0.11.0, unlocking writes. The /import route 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).
  • SchemaTooNew is 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 with 403 SchemaTooNewupdate 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 Graczy section and the ## Mapa registry 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