The entity model¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Every tracked thing in Nerthus is one encja (entity): a named record carried in
Markdown and exposed by the daemon as a structured object. This page is the
contributor model of that record: the index files and how they merge, machine
authorship (the (auto) marker), and the claim ledger that makes
nerthus.entities.md hand-editable. For the plain introduction, start at the
entities overview; the closed tag table, the temporal range grammar,
and the write rules live in the tag schema reference.
Concepts¶
- Encja / Entity — one named record. Its identity is its canonical name plus its
type (e.g.
NPC/Opat Perrin,Lokacja/Ithan). Names are matched through the Polish-aware resolver; see Name resolution. - Type — one of eight fixed Polish kinds (below); the set is closed.
- Tag (
@tag) — a typed attribute carried as a flat- @tag: valuelist item under the entity heading. No tag takes nested child bullets. The recognized set is a closed schema, validated on write. - Prose — any line in a block that is not a tag bullet. It is operator comment: the model ignores it, the merge preserves it (below).
- Claim (roszczenie) — one
- @forma_sesyjna: <forma>line: it claims that literal session form for its block and routes mentions there. - Temporal value — a tag value may carry a
(from:to)validity range. - Referenced properties (
@ref) — a block may point at a file (the ref target) that holds its operator-authored properties; the block keeps the entity's identity and every tool-written tag (below). - Soft-delete — an entity is retired with
@status: Usuniętyand stays in the index with its history.
The eight entity types¶
The eight types (NPC, Grupa, Lokacja, Mapa, Gracz, Postać, Przedmiot, Region — the
operator descriptions are in the entities overview) are baked in. An
entity's type is its @typ tag; the ## Category heading only groups entities
in the file. At import, Grupa entities are derived from the
Organizacje/{Graczy,NPCów} directory tree — one Grupa per organization directory.
A Region is a named, time-scoped grouping of locations, never minted by the
importer — owned by the region model.
The Markdown shape¶
Every entity's block lives in the index nerthus.entities.md (and its overflow
siblings, below); a block's operator-authored properties may instead live in a
referenced file it names with @ref (below).
The index is split: ## Postacie Graczy is durable —
hand-editable, API-written, never regenerated — while ## NPC, ## Grupa,
## Lokacja, and ## Mapa are machine-derived from the sources, yet still
hand-editable: regeneration merges each block with its disk twin instead of
overwriting it (see the fixpoint merge below). A third ownership category exists
outside this file: the generated Sesje.md files of the
session registry are fully generated — regenerated
wholesale, hand edits overwritten, no merge. The file opens with a short Polish
header (# Indeks encji Nerthus) that points at this documentation rather than
restating it (below). Each entity is a level-3 heading naming it, followed by a flat
list of @tag items, optionally interleaved with operator prose; an optional
level-2 category heading groups entities for human readability:
## NPC
### Opat Perrin
- @typ: NPC
- @alias: Perrin
- @lokacja: Ithan (:2026-06)
- @lokacja: Thuzal (2026-07:)
- @grupa: Gildia Teologów (2024-01:)
- @status: Aktywny
- @plik: /Postaci/NPC/Thuzal/Opat Perrin.md
- The same tag may repeat for multi-valued and time-scoped attributes (note the two
@lokacjalines above); evaluation rules are in the tag schema reference. - The tag list is flat. Every bullet is a
- @key: valueline, and an indented bullet under one is not read as anything.
Prose in a block¶
A block line that is not a - @tag: bullet is operator prose — a free comment, in
the same relationship to the tag list that a session's body has to its metadata. The
scanner ignores it (MarkdownScanner.ScanEntityIndex skips any non-bullet line), so it
carries no attribute, resolves no name, and routes nothing. It exists for the human
reading the block.
The merge preserves it. A prose run rides as its own entry, positioned among the tags it sits between, and is re-emitted fenced by exactly one blank line on each side — that canonical fencing is what makes the fixpoint byte-stable, since a block already spaced that way re-imports unchanged. A run is deliberately not glued to a neighbouring tag: a machine-owned tag that gets stripped at merge must not take an operator's comment down with it.
The fencing is a normalization, so blank-line spacing inside a block is the one thing about a hand edit that is not preserved: a run written with no blank line around it gains one, and a run padded with several loses the extras. Line breaks within a run are kept, so a multi-line comment stays multi-line. Blank lines are the block's only structural separator, which is why they are the merge's to set and not the operator's.
The importer reads the hand-authored Polish sources (Postaci/, Świat gry/,
Organizacje/, …) and emits nerthus.entities.md; when the index already carries
the durable ## Postacie Graczy section, it carries it verbatim and regenerates only the
machine sections, merging each block with its disk state
(adoption). It never rewrites the sources, and files are written
UTF-8 no BOM with the original newline style — the data-owner contract is the
architecture's.
Index files, merging, and where writes land¶
The daemon scans two kinds of index file, in this load order: nerthus.entities.md
(the base index) first, then the hand-managed overflow files
nerthus-*-ent.md (sorted).
Blocks with the same name and the same type (from @typ, else the ## Category)
are merged into one entity across the base index and the overflow files: their tag
histories are concatenated, never replaced. An overflow block must therefore
repeat the @typ (or sit under a matching category heading) to merge; a same-named
block of a different type stays a distinct entity.
Overflow primacy — base first, overflow wins. Because scalar resolution tie-breaks prefer the later-loaded entry (below), an undated hand-edit in an overflow file overrides the bulk-generated base value without rewriting the base — for every type, locations and maps included. Hand-edits are picked up automatically — self-heal is the architecture's contract.
Writes land in the file the block actually lives in. A tag update or soft-delete
resolves the entity's source file (base or overflow) and splices that file. New
entities are appended to the base index under their type's category heading; new
Mapa blocks land in the base index's ## Mapa section. The daemon never creates
overflow files itself.
Writes into the durable ## Postacie Graczy section are permanent —
the roster is Players & characters's. The machine sections
are durable in a different way: a reindex (Invoke-NerthusReindex) rebuilds all
four from the sources and then merges each rebuilt block with its on-disk twin —
the disk block's tag lines win per tag key. A hand edit or API write into a machine
section therefore survives regeneration; only the machine-owned keys are re-derived.
Referenced properties — @ref¶
@ref is a scalar tag valid on any entity block in nerthus.entities.md or an
overflow nerthus-*-ent.md. When present, the entity's operator-authored properties
are read from the referenced file instead of the inline block; the block keeps
the entity's identity and every machine- or tool-written tag (the split below). The
merged result is what the API serves — GET /entities/{name} answers identically
whether properties are inline or referenced. A second active @ref line on one
merged block is a broken ref, never a silent tie-break.
The referenced file takes one of exactly two shapes.
Leading list. The file begins with a bare - @tag: value list, no heading
above it. The first markdown heading — any level — terminates the property
section; everything after it is ordinary prose the daemon ignores. Blank lines,
prose, and plain bullets between properties are skipped; fenced lines are invisible;
a heading on line 1 means a valid, empty property set:
- @lokacja: Nithal
- @grupa: Gildia Kupców (2020-01:)
## Reputacja
...ordinary prose, invisible to the daemon...
This is a new authoring convention: measured at planning time, zero of 1034 lore files opened with a property list, so old lore files carry none until adoption migrates them file by file.
Index shape. The file carries the index grammar itself — ## Category
headings, ### Name entity headings, - @tag: value bullets. The block whose
heading equals the referring entity's name verbatim (qualified headings like
Karczma (Torneg) included) and whose @typ, when the referring block carries
one, matches with the range suffix ignored supplies the properties. Zero or several
matching blocks is a broken ref — a reader never guesses:
Detection is one rule: a non-empty leading list wins — even in a file that is also
index-shaped — else the uniquely matching block, else the ref is broken
(InvalidForm for a prose file, NoMatchingBlock for an index-shaped one).
Temporal values work unchanged in both shapes: values are kept raw with their
(from:to) suffixes, so the whole range machinery applies to referenced
properties.
The replacement split — which side owns each key¶
"Reads properties from the file instead" cannot mean all keys: machine and tool
code writes tags into index blocks continuously (import enrichment, PU settlement,
currency, transfers, soft-delete, the Discord and rename handlers), and those
writes must keep landing. The split is per key, and the settled table below is the
contract — every schema key plus @ref itself, one side each:
| Tag | Kind | Side | Tool writer(s) |
|---|---|---|---|
@typ |
scalar | index | importer, create routes |
@ref |
scalar | index | PATCH /entities/{name} (adopt/drop) |
@status |
scalar | index | importer, soft-delete, character demotion |
@duplikat |
multi | index | importer (machine-owned) |
@drzwi |
multi | index | importer (auto) doors, traversal promote |
@pu_startowe |
scalar | index | importer roster |
@pu_suma |
scalar | index | importer roster, PU settlement |
@pu_zdobyte |
scalar | index | importer roster, PU settlement |
@pu_nadmiar |
scalar | index | importer roster, PU settlement |
@ilość |
scalar | index | currency, transfers |
@plik |
scalar | index | importer |
@należy_do |
scalar | index | transfers, currency, importer roster, rename retarget |
@discord |
scalar | index | the Discord-channel route |
@forma_sesyjna |
multi | index | importer (auto) claims |
@margonemid |
scalar | index | importer (roster, Mapa catalogue) |
@slug |
scalar | index | importer (Mapa), map checkup |
@url |
scalar | index | importer (Mapa), map checkup (auto) |
@wymiary |
scalar | index | map create seed |
@typ_lokacji |
scalar | index | map create seed |
@outerior |
scalar | index | importer enrichment |
@inheritsfrom |
scalar | index | none (reserved) |
@isexterior |
scalar | index | none (reserved) |
@first_session |
scalar | index | none (reserved) |
@floor |
scalar | index | none (read-only honored) |
@base_name |
scalar | index | none (read-only honored) |
@nazwa_nerthus |
scalar | index | none |
@lokacja |
scalar | ref file | importer enrichment, currency (see the conflict rule) |
@region |
scalar | ref file | none |
@koordynaty |
scalar | ref file | map create seed (create-time only) |
@tematy_zastrzeżone |
scalar | ref file | importer roster bootstrap (once) |
@url_nerthus |
scalar | ref file | map create seed (create-time only) |
@alias |
multi | ref file (union) | rename route, importer (auto) for a derived spelling, bare for a roster one |
@grupa |
multi | ref file (union) | importer (Heroic tier) |
@generyczne_nazwy |
multi | ref file (union) | none |
@url_grafika |
multi | ref file (union) | map create seed |
@url_dokument |
multi | ref file (union) | none |
@url_strona |
multi | ref file (union) | none |
@plik stays index-side and keeps its meaning — the prose file session fan-out
writes into. @ref and @plik may point at the same file, and often will.
The conflict rule¶
Per tag kind, because the hazard is per kind:
- A ref-file scalar key present on both sides: the ref file wins at the entry
level — the index entries for that key never enter the merged list, so a dated
index entry cannot out-resolve an undated file entry under last-active-wins — and
the collision is a
konflikt-reflint warning. The importer re-emits@lokacjainto machine blocks, so a ref target that restates@lokacjacarries a standing finding by design; the fix is removing the line from the file. - A ref-file multi key: the two sides union — all-active evaluation
returns every value, nothing shadows — so tool appends into the index (a rename's
@alias, enrichment aliases, the Heroic@grupatier) stay effective on referenced entities. - An index-side key stated in the referenced file: ignored for the model and
reported as a
konflikt-refwarning. One structural exemption: in an index-shaped target the selected block's@typis the selection key and raises nothing. - A ref-file key present only in the index stands — a half-migrated entity loses no fact.
Failure modes — what a contributor sees¶
Every class degrades that one entity: it resolves with its inline index tags
only, the projection marks it RefBroken with the class as RefBrokenReason,
integrity reports one Error row (Tag = ref,
Issue = RefBroken:<Reason>:<path>), and the post-converge lint names the file
and the reason. A broken ref never prevents boot or a model build.
RefBrokenReason |
Trigger |
|---|---|
Missing |
the target file does not exist (the ref heals when it appears) |
Empty / AbsolutePath / EscapesRepo / PrivateDir / ChokeFile / IndexFile / NotMarkdown / Symlink |
the path guard refused the value (path rules below) |
InvalidForm |
the target has neither a leading list nor any index-shaped block |
NoMatchingBlock |
index-shaped target, but zero — or two or more — blocks match name + @typ |
Chain |
the selected properties themselves carry @ref — depth is 1, chains are refused, so no cycle detection is ever needed |
MultipleRefs |
more than one active @ref line on one merged block |
Write behavior¶
A PATCH /entities/{name} targeting a key the split routes to the ref file — and a
POST /entities seeding such a key alongside @ref — is refused: HTTP 422,
stable error id RefTargetProperty, body naming the file to edit. The property is
edited by editing that file, which is the workflow @ref exists to serve.
Index-side keys keep writing through the daemon unchanged, and @ref itself is an
ordinary plain-scalar write under entity.write, so entities adopt and drop refs
over the API with no new route. The provenance fields the projection exposes
(RefFile, RefForm, RefBroken) are in the
tag schema reference.
Path rules and freshness¶
A ref value is repo-relative, optional leading /, forward slashes — the @plik
value convention. Refused outright: paths that escape the repository (..
included), absolute or drive-qualified paths, anything under .nerthus/, the
robot-owned choke files, nerthus.entities.md itself and every overflow file (the
index is never its own ref target — refs hold facts, never blocks-of-record), and
non-.md paths. A symlinked target resolves as its literal path and is refused;
links are not followed, matching the source walks that skip reparse points.
Editing only the referenced file is reflected on the next request: resolved ref targets join the model fingerprint, so the self-heal the architecture owns notices target edits, deletions, and late-created targets alike.
The tag schema, in brief¶
Every recognized tag has one of three kinds:
- multi — repeatable; evaluation returns every active line (
@alias,@grupa,@drzwi,@forma_sesyjna, the link tags, …). - scalar — a single current value that tools replace in place (
@plik,@slug,@margonemid, the@pu_*snapshot, …). - scalar, historical — a scalar whose updates close-and-append instead of
replacing, keeping the full timeline (
@lokacja,@status,@należy_do,@ilość,@koordynaty,@typ,@typ_lokacji,@outerior,@url).
The tag schema reference owns the full contract:
the closed tag table, the link-value grammar, the temporal (from:to) range
grammar and its evaluation rules (last-active-wins for scalars, all-active for
multi tags), the historical write rule, the closed-schema validation seams with
the integrity finding set, the diacritics rule, and the projection field table.
Two structural rules belong here:
- Containment has exactly one direction: the child's
@lokacja. The former parent-side@zawieraconvenience is retired from the closed schema; a leftover@zawieraline is anUnknownTagintegrity error like any other unrecognized key. - A Gracz's content triggers are the typed tag
@tematy_zastrzeżone(projected asRestrictedTopics) — the one Gracz-block field for them (Players & characters). - The schema has a type dimension. A universal set (
@typ,@status,@alias,@nazwa_nerthus,@ref,@plik,@generyczne_nazwy,@duplikatand the link family) is legal on any entity; every other key belongs to named types.@ilośćis aPrzedmiotconcept,@koordynatyaMapa/Lokacjaone,@wymiaryaMapaone. A key outside its type's set is legal grammar in the wrong place, so it raises the Warning-levelOffTypeTag:<key>rather than blocking the write: a corpus this old has accumulated some, and an authoring mistake is not worth refusing a session over.GET /schemapublishes the map astagsByType, so a client derives it instead of hardcoding it.
Note @lokacja on a Mapa means depiction, not containment: the map depicts that
place. Containment is still the child's own @lokacja on a Lokacja. The type list
cannot express that difference, which is why the distinction stays written down here.
Two evaluation facts the merge sections below rely on: undated scalar entries
tie-break by load order — the later file wins, giving overflow files their primacy
(above) — and "current" is always a computed query over the history, evaluated at
-ActiveOn (default: now).
Soft-delete¶
Removal is the historical write applied to @status — a transition, not a
deletion. Retiring a location in July 2026 closes the active status and appends
Usunięty:
### Ogród Tussala
- @typ: Lokacja
- @status: Aktywny (2024-01:2026-07)
- @status: Usunięty (2026-07:)
Remove-NerthusEntity performs exactly this edit; no bullet is ever deleted. A
Usunięty entity stays in the index with its full history queryable. The name
resolver stops resolving to it, listings can exclude it via the status filter, and
a soft-deleted currency holding leaves the supply as burned coin
(Currency & economy). The operator recipe is
Add, edit, and retire entities.
The entity record the daemon exposes¶
The daemon parses the Markdown into a structured object; cmdlets return this shape
with temporal fields already resolved to the requested -ActiveOn date. All values
are the raw tag strings — the daemon does no numeric coercion (Quantity is
"50", not an integer). Every record carries the universal fields Name,
Heading, Type, Category, Status, Aliases, FilePath, and
DuplicatesDropped. The type-specific fields — Owner, Groups, Location,
Doors, Quantity, Coordinates, RestrictedTopics, SessionForms — are scoped by
@typ. Tag-presence fields (MargonemId, Url, Slug, Dimensions, the Pu*
snapshot, the link arrays, …) are added to the record of any entity carrying the tag.
The full field ⇄ tag projection table is in the
tag schema reference.
The shape rule is declared(type) ∪ present: the record carries the fields its
@typ declares, plus any field whose tag the block actually holds. A Gracz does not
answer with the Doors or Coordinates it can never have, while an entity that does
carry an off-type tag still shows it rather than hiding it.
The rule removes keys a /v1 client may be reading
Type-scoping drops Doors, Quantity, Coordinates, RestrictedTopics, and
SessionForms from every record whose @typ neither declares nor carries them — a
character no longer answers with an empty Doors array. A consumer that read the key
unconditionally now reads $null. GET /schema publishes tagsByType and
universalTags, so a client can predict the shape rather than probe for it.
The union half is load-bearing in two cases, both real:
- A retyped entity keeps its history.
@typis a historical scalar, so aPostaćwho becomes an NPC slides across without a rename. The block still physically holds@pu_startoweand@pu_suma, and a strictf(type)shape would drop settled PU history from the response on the day the type flipped. - A mis-authored
@reftarget stays visible.Merge-NerthusRefPropertiesdeliberately keeps unknown keys on the record so integrity can raiseUnknownTagagainst them. Hiding them from the projection would make a broken reference invisible in the API while still erroring in integrity, which is the worst of the two.
Because the projection reads a fixed key list, a tag outside the closed schema has no
field to land in at all, and is invisible in the API even though Test-EntitySchema
raises UnknownTag against it at Error severity. A reserved UnknownTags dictionary
would let integrity stop being the only place a bad key shows; until it exists,
GET /entities/integrity is where to look.
POST /resolve is deliberately not this shape. It answers
{ name, typ, plik, stage, confidence, ambiguous, candidates }: a resolution envelope,
not an entity envelope. It says how a name was matched, and plik rides along because
any entity may be a distribution target.
The currency holding view (GET /currency, /currency/{name}, /currency/report) is
also its own shape, and stays one. EntityName, Denomination, DenomShort, Tier,
Balance, Kogi, OwnerType, Class, OwnerStatus describe a holding as the economy
reads it; several are computed rather than tag reads. It is a domain projection over
Przedmiot blocks, not the Przedmiot record with extras.
Machine authorship & provenance — the (auto) marker and git history¶
A tag line carries its authorship in the line itself. A value carrying the
(auto) marker is machine-written: re-derivable, and free to land differently
on a from-scratch rebuild. An unmarked value is operator-authored:
authoritative, preserved verbatim by every merge. Matching always folds the marker
off — Thuzal/Rezydencja Tussal (auto) and Thuzal/Rezydencja Tussal are the same
claim with different authorship — and the daemon strips it from every projected
value except SessionForms, which keeps claim lines raw.
The marker sits before any temporal range (exact grammar in the
tag schema reference), so a marked line stays
range-closable — retiring it edits only the range. Four tag families carry it
today: @forma_sesyjna claims (below), the log-derived @drzwi doors, the
checkup-refreshed Mapa @url
(Locations & maps owns the latter two derivations), and
@alias.
@alias is the one where the marker separates lines within a key rather than
marking a whole family, so read it carefully. Two of the three places the importer
writes an alias derive it — a Mapa's heading with its subdivision removed, and a
Lokacja's normalized spelling — and both carry the marker. The third transcribes
the roster's - Aliasy: row, which a person wrote, and stays bare. Marking all
three would make every alias in the index machine-written, and the distinction
would name nothing.
The intended invariant is that an unmarked @alias means a human chose it —
what lets a reader, and a trained layer, tell an accepted identity from a generated
one. It does not hold yet. The marker reaches only lines written from now on,
because a line already on disk keeps its own text through the merge, so the aliases
written before the marker existed are still bare whatever their provenance.
Establishing the invariant takes a one-off pass over the existing lines, rendered
by vm/New-NerthusAliasMarkerPlan.ps1 and applied by an operator. Until that lands,
read an unmarked @alias as unknown authorship, not as a human's.
Lineage lives in git history. The index is a committed file, so every
merge, split, rename, and re-route is a reviewable diff. The three projection
tags that once carried an in-file provenance log — @nadpisanie,
@scalono, @mention_count — are retired: a pre-claims index may still hold
them, and the first fixpoint merge strips them for good. @duplikat stays
machine-owned and is re-derived at every merge.
Identity vs. presentation — the derived path¶
The stable identity that references point at is the entity name (for Gracz/Mapa,
the opaque @margonemid); classification and topology (@typ, @lokacja,
@drzwi) are temporal tag values that move freely. A directory-style path
like Postaci/NPC/Thuzal/Opat Perrin is only a derived view of those tags, never
the identity. Get-NerthusEntityPath computes it from the type +
containment/ownership chain as of -ActiveOn, so the path moves on its own over
time. A Postać that becomes an NPC returns Postaci/Gracze/<Gracz>/<name> on an
early date and Postaci/NPC/<lokacja>/<name> later — no rename, no broken
references. A shared interior (one corridor seen under several exteriors in its
@forma_sesyjna) returns several paths (Shared = $true) — the "symlink" view.
The character harvest — NPCs proposed from transcripts¶
A game log records who spoke each line, and most of those nicks belong to a Postać
or an NPC the repository already knows. The rest are the campaign's undocumented
cast, and the importer proposes them as ## NPC blocks.
Evidence comes from the committed transcript archive, nerthus.logs/, reached the way
the door derivation reaches it: a transcript counts when some
session's @Logi names it. An orphan sidecar nobody references attests nothing, and a
@Logi URL never fetched contributes nothing until it is.
Four gates stand between a nick and a proposal:
| Gate | What it drops |
|---|---|
| Shape | Prose that reached the speaker field through a mis-split line, whisper pairs (Anward -> Karendar), anything not shaped like a name |
| Known | Nicks reaching an existing entity — headings and @alias values, folded and declension-stemmed, so an inflected mention is not a second person |
| Claimed | Nicks a block already claims through @forma_sesyjna, and names a block already carries |
| Weight | Nicks under three lines across the whole corpus |
Title-prefixed forms fold together, because a Polish honorific is a role rather than
part of a name: Burmistrz Filemon Ralgar and Filemon Ralgar are one candidate, the
longer form taking the heading. Folding stops there. Two nicks that merely share a
first name stay apart, since an over-merge silently fuses two people while an
under-merge costs a narrator one @alias line.
A proposal carries the review flag, its first attested date, and every literal it folded:
### Burmistrz Filemon Ralgar
- @typ: NPC
- @first_session: 2024-11-01 (auto)
- @forma_sesyjna: Burmistrz Filemon Ralgar (auto)
- @forma_sesyjna: Filemon Ralgar (auto)
- @status: Niepewny (auto)
There is no @lokacja: the harvest reads voices, and where a person belongs is a
judgement the narrator makes. Nor is there a @plik — that arrives when someone
writes the character sheet.
What a narrator does with one¶
| Answer | Gesture | What the next import does |
|---|---|---|
| Yes, this is a person | Rewrite @status to Aktywny (drop the marker) |
The block is operator state and is preserved; nothing re-proposes the name |
| Yes, but the name is wrong | Edit the heading | The claim lines travel with the block, so the literals stay routed there |
| No, this is not a person | Rewrite @status to Usunięty |
The rejection is operator state and holds across every later run |
| It is someone we already have | Move the @forma_sesyjna lines onto that block and delete the proposal |
The literals are claimed, so nothing is minted again |
An unreviewed proposal is re-emitted every run. That is what keeps the merge from reading it as a ghost the fresh build dropped, and it is why deleting a proposal without claiming or rejecting it brings the proposal back.
The harvest runs wherever the import runs — the adoption, every reindex, and the last step of a session close (Sessions model), where the closing session's transcripts are fetched first so its cast lands with it.
Operator hand edits — the claim ledger¶
The operator channel is the index itself. nerthus.entities.md is
hand-editable end to end: the durable ## Postacie Graczy section by design, and the machine
sections through the fixpoint merge below, which regenerates them around
operator edits. Every correction is a plain edit of the block it concerns.
The claim ledger — @forma_sesyjna routes mentions¶
Every - @forma_sesyjna: <forma> line on a block claims that literal session
form for the block. The ledger is the mention-routing plan: when the importer
meets a session @Lokacje form, it consults the claims before every
heuristic — a claimed form goes to its block, and only unclaimed forms fall
through to the deterministic pipeline
(Locations & maps). A (auto) suffix marks a
machine-appended claim; an unmarked line is an operator claim. Two rules keep the
ledger honest:
- A form claimed by two blocks is a routing conflict. The mention is
quarantined — a
@status: Niepewnyblock with reasonkonflikt-roszczeń— and the lint reports it (below). Nothing routes on a contested claim. - Deleting a claim line does not forget the form. The form becomes unclaimed, and the next reindex routes it heuristically again. To take a form away from a block, move its line to the right block — don't delete it.
Routing consults only claim lines; @alias remains the resolver-token tag for
clean alternative names (Name resolution). Every distinct
session literal — traversal routes and clean variants alike — lands in
@forma_sesyjna as a claim, so a clean variant may appear under both tags with
different roles: as @alias it resolves names, as @forma_sesyjna it routes
mentions.
The operator gestures¶
Every correction is a file edit; the block is the unit:
| Gesture | Edit |
|---|---|
| Adopt a machine guess | delete the (auto) marker on the line (claim, door or alias alike) |
| Reject a machine door | close its range on both endpoint blocks — - @drzwi: X (auto) (:2026-07) stays matched at merge, so the rejection survives re-import (Locations & maps owns the pair semantics) |
| Reject a machine claim | close its range the same way — a range-closed @forma_sesyjna line still matches at merge and is never re-appended |
| Re-route a mention | move the claim line to the right block |
| Merge two places | move the loser's claim lines onto the survivor (note below) |
| Split a wrongly merged place | create city-qualified blocks and distribute the claim lines (next subsection) |
| Rename | edit the heading; keep the old name as @alias |
| Retype | edit @typ — the machine will not re-mint the name in the old section |
| Reparent | edit @lokacja |
| Correct anything else | edit the tag line |
| Pre-register a future entity | author a new block by hand |
Notes:
- Merge: after moving the loser's claim lines, add the loser's name as
@aliason the survivor, then soft-delete the loser (@status: Usunięty). Its mentions follow the moved claims. - Retype: a block owns its name across all machine sections — after the
@typedit the regeneration never mints a same-named twin under the old## Category. - Future entity: a hand-authored block may claim a form no session has used
yet; the claim routes it on first appearance. Until then the lint flags it as
roszczenie-bez-wzmianki— for a pre-registration that finding is fine.
A hand-authored block the sources don't produce — a future entity, a renamed or retyped survivor — is a disk-only block; the merge preserves it verbatim (below).
Homonyms and the split¶
Block headings must be unique. Two blocks with the same name and type
chimera-merge at parse time — the cross-file merge sees one entity — and only the
first is reachable by writes; the lint flags the duplicate heading, and the API
refuses to create one outright with 409
(tag schema reference). Homonyms are therefore
qualified by city. To split a wrongly merged place, create one block per
city — ### Gildia Teologów (Thuzal), ### Gildia Teologów (Ithan) — give
each the bare name as a shared @alias (- @alias: Gildia Teologów), and
distribute the claim lines: each city-prefixed form moves to its city's block.
The shared bare alias is also the split signature the heuristics key on.
When ≥ 2 live blocks share a bare @alias, a new, unclaimed mention of that
bare name never resurrects a bare block: the normalizer qualifies it by the
session's exterior city and routes — or mints — Gildia Teologów (Miasto). A
mention whose exterior city cannot be resolved has no home in the family and is
quarantined with reason homonim-bez-miasta.
The fixpoint merge — how edits survive re-import¶
Every regeneration entry point — the importer and the reindex — builds the machine
sections (## NPC, ## Grupa, ## Lokacja, ## Mapa) fresh from the sources,
then merges them per block with the on-disk index:
- The disk block wins. Its tag lines are carried verbatim — order, dates, formatting. What the machine may append depends on which of three kinds the key is, and the three are not interchangeable.
- Simultaneous keys accumulate, matched by value.
@forma_sesyjna,@drzwiand@alias. A block holds several at once, so the machine appends a line only for a value new this run, with the marker and any range folded off for the comparison. A form already claimed on the disk block — marked or not — is never duplicated or overwritten, and a range-closed line still matches, so an operator's rejection (the range-close gesture above) is never re-appended. - Every other key is appended only when the key is absent entirely. This is the
default branch, and it covers everything outside the three named sets — the
temporal scalars
@url,@lokacja,@statusand their family in the tag schema among them. A dated succession belongs to the write path, which closes the active range and appends the new entry; the importer only ever seeds one, undated. Testing the whole key is what stops a seed landing inside a timeline it does not own. - Machine-owned keys are re-derived.
@duplikatis stripped from the disk block and re-emitted fresh; the retired projection tags@mention_count,@scalono, and@nadpisanieare stripped the same way and never re-emitted.
Do not generalise value-matching to the default branch
Value-matching every key that is not machine-owned reads as the obvious simplification, and the damage it does is narrower than it first appears — which is why the reasoning matters more than the instinct.
It is not that a retired value would return. The value key folds the range
off, so a seed equal to any value already on the block matches it and is
suppressed under either rule. Measured on the development clone: of the 105
Mapa blocks holding two @url lines, the source seed folds onto a line already
present in 105 of them — 100 the range-closed value, 5 the live one — so
value-matching @url would re-append none of them.
The real hazard is a seed matching neither disk value. Under value-matching it lands as an undated line among dated ones, and no reader can then tell which line covers now — the succession loses its meaning without any line being wrong on its own. That population is 0 of 105 today, so this is latent rather than live, and it is latent only because the sources happen to trail the write path rather than diverge from it.
The standing acceptance test for any change to this section: no undated value is appended to a key that already carries a dated line. Fixpoint stability does not catch it, because a wrongly appended line is stable once appended.
- Disk-only blocks are preserved verbatim, their prose runs included. One
exception: a machine quarantine ghost — a block whose last undated
@statusis the markedNiepewny (auto)— is dropped once the fresh build stops emitting it, i.e. once its form got claimed or resolved. The marker is what identifies the ghost, so a hand-authored bareNiepewnyreads as operator state and stays. - The durable section is untouched —
## Postacie Graczyare carried verbatim.
Idempotence is fixpoint stability: import(import(X)) == import(X),
byte-identical. It is not history-independence — a from-scratch rebuild without
the previous index may place its (auto) claims differently than the incremental
history did. The machine sections are state with a durable operator overlay —
the durable-section precedent extended to all four.
The in-file header¶
The header the importer writes says only that the file is editable, and points at the documentation for the rest:
# Indeks encji Nerthus
# Ten plik można edytować ręcznie — zmiany przetrwają ponowny import.
# Zasady edycji, znaczenie tagów i kontrola spójności: dokumentacja Nerthus.Core,
# "Running the campaign" → "Add, edit, and retire entities".
It used to be an instruction card restating the whole hand-edit contract in Polish at the top of every generated index. That made the contract two documents: the card and this model, each free to drift from the code and from each other. A pointer cannot drift. The contract lives in Add, edit, and retire entities for operators and here for contributors, and the file names where to find it.
Lint and apply¶
Workflow: edit nerthus.entities.md → Test-NerthusClaims (lint, read-only) →
Invoke-NerthusReindex (apply now: full regeneration + model refresh, no daemon
restart — the response embeds the same lint, so a conflict never takes effect
silently) → verify via Get-NerthusEntity / Get-NerthusLocation. The lint
findings, each with a plain-language Polish reason:
| Finding | Meaning |
|---|---|
konflikt-roszczeń |
one form claimed by ≥ 2 blocks — mentions quarantine until one line remains |
roszczenie-na-usuniętym |
a claim on a soft-deleted block — move the line to a live block |
zdublowany-nagłówek |
the same ### heading appears twice within one type — only the first block is reachable; qualify by city. A Lokacja and the Mapa depicting it share a name by design and never collide |
roszczenie-sprzeczne-z-rejestrem |
an (auto) claim whose form names a block of its own, while the claiming block is a fragment of that form — a legacy - separator split; delete the line and the fragment block |
roszczenie-bez-wzmianki |
an operator claim matching no observed session form — a typo (near-miss suggestions attached) or a pre-registered future entity (fine) |
Governance is git review: the index is a committed file, so hand edits travel
as commits and merge requests like any lore change, and every applied plan is its
own diff. There is no proposal workflow; location.override gates the reindex
route. Routes, envelopes, and capabilities: the
API reference.
Real examples¶
A Grupa — Gildia Teologów, with a distribution target so session writes that
name it land in its own file. Membership is carried on each member's own block
(- @grupa: Gildia Teologów on Opat Perrin), never on the Grupa itself:
## Grupa
### Gildia Teologów
- @typ: Grupa
- @alias: GT
- @lokacja: Thuzal (2023-01:)
- @status: Aktywny
- @plik: /Organizacje/NPCów/Gildia Teologów/Opis Ogólny.md
Zakon teologów działający na terenie Thuzal.
A Lokacja — a residence in the world hierarchy, placed under its town, carrying a door edge, its Margonem binding, and the machine claims for the session forms that route here:
## Lokacja
### Rezydencja Tussal
- @typ: Lokacja
- @lokacja: Thuzal (2021-01:)
- @drzwi: Thuzal (2021-01:)
- @slug: rezydencja-tussal
- @forma_sesyjna: Rezydencja Tussal (auto)
- @forma_sesyjna: Thuzal/Rezydencja Tussal (auto)
- @status: Aktywny
- @plik: /Świat gry/Thuzal/Rezydencja Tussal.md
A split homonym family — the theologians' guild chapters after an operator
split by hand: city-qualified headings, the shared bare @alias (the split
signature), and the claim lines distributed per city. The unmarked line is an
operator decision; the (auto) lines stayed machine:
### Gildia Teologów (Thuzal)
- @typ: Lokacja
- @alias: Gildia Teologów
- @lokacja: Thuzal (2024-01:)
- @forma_sesyjna: Thuzal/Gildia Teologów (auto)
- @forma_sesyjna: Gildia Teologów
- @status: Aktywny
### Gildia Teologów (Ithan)
- @typ: Lokacja
- @alias: Gildia Teologów
- @lokacja: Ithan (2024-03:)
- @forma_sesyjna: Ithan/Gildia Teologów (auto)
- @status: Aktywny
The operator claimed the bare Gildia Teologów for the Thuzal block, so bare
mentions route there. Had that line been deleted instead, a new bare mention would
fall to the split fallback — qualified by its session city, or quarantined as
homonim-bez-miasta.
Cmdlet surface¶
Thin Verb-Nerthus* wrappers; routes, parameters, envelopes, and capabilities are
in the API reference.
- Read & validate:
Get-NerthusEntity,Find-NerthusEntity,Get-NerthusEntityPath,Get-NerthusEntityHistory,Test-NerthusEntityIntegrity. - Write:
New-NerthusEntity,Set-NerthusEntity,Set-NerthusEntityName(rename: the heading changes, the old name stays as@alias, exact@należy_doreferences retarget, and an already-taken name is refused),Remove-NerthusEntity(soft-delete).New/Set-NerthusLocationEntityandNew/Set-NerthusMapEntityare type-specialized wrappers with their own dedicated routes and handlers. Gracz and Postać writes go through the dedicated player/character cmdlets — Players & characters. - Claims & reindex:
Test-NerthusClaims(lint the ledger),Invoke-NerthusReindex(apply hand edits now).
Every mutating route passes the single write gate and fires the
BeforeWrite/AfterWrite hooks — the API reference
owns the middleware chain.