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-NerthusRepo → POST /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:
-
maps.md(the bootstrap Margonem catalogue) — recovered from.robot/res/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 ←
.robot/res/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). -
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.
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 is0.8.0. A real import stamps it to0.8.0directly; the daemon expects0.8.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.8.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/VM angle belongs to the sync design.- Preserve-on-re-import is keyed on presence, not on the pointer. The
durable
## Gracze/## Postacie Graczysections 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, 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¶
- 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 - Set up the repository — the step-by-step operator walkthrough