Skip to content

Adoption: the import model

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 the legacy .robot/res/ state, 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 Set up the repository; this page explains the model behind it.

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 writes
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.8.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 .robot/res/. It writes only the generated index and its own state under .nerthus/. 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, Council 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). 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 ## Gracze 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 ## Gracze section, import takes the preserve path: the durable ## Gracze / ## Postacie Graczy sections 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 durable ## Gracze 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

Within one source walk, when two sources yield the same (name, type), 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 roster sections are never rebuilt. After a real (non-preview) import the format pointer is stamped to the current index-format version, 0.8.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) Gracz + Postać nick, PU triple, aliases, PRFWebhook@prfwebhook, ID Margonem@margonemid, owned-character links → @należy_do, bold link = active character → @status: Aktywny
Postaci/Gracze/** Postać (sheet path only) file path → @plik; /Martwi/ path segment → @status: Nieaktywny; sheet contents are never parsed
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 .robot/res/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 ← .robot/res/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).

  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.

Legacy .robot recovery, and where webhooks live

Import reads exactly two legacy files, both under .robot/res/: maps.md (the Mapa registry bootstrap, Pass 2 — read only when the index has no ## Mapa section yet) and pu-sessions.md (the PU ledger, above). .robot.new/ and .robot.local/ are never touched; the legacy directories are left in place, untouched.

Webhooks live in the index, not in a secrets file. Gracze.md embedded per-player Discord webhooks (PRFWebhook: https://discord.com/api/webhooks/…); the bootstrap path carries each one onto its Gracz block as @prfwebhook, and the committed index is the one and only source (roster model). The retired .nerthus/local/secrets.json is never read or written — a leftover file is inert, and the operator may delete it. The exposure class is unchanged: Gracze.md was committed with the same URLs. The generated index stays at the repo root so it is committed and human-readable; everything else lives under .nerthus/ per Architecture.

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.8.0. A real import stamps it to 0.8.0 directly; the daemon expects 0.8.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.8.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/VM angle belongs to the sync design.
  • Preserve-on-re-import is keyed on presence, not on the pointer. The durable ## Gracze / ## Postacie Graczy sections 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, no writes
Initialize-NerthusRepo            # write the index + move roster notes + recover the ledger; pointer -> 0.8.0
Get-NerthusSchemaVersion          # on-disk vs expected (both 0.8.0 after adoption)

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