Skip to content

The entity model

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 seven fixed Polish kinds (below); the set is closed.
  • Tag (@tag) — a typed attribute carried as a flat - @tag: value list 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.
  • Soft-delete — an entity is retired with @status: Usunięty and stays in the index with its history.

The seven entity types

The seven types (NPC, Grupa, Lokacja, Mapa, Gracz, Postać, Przedmiot — 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.

The Markdown shape

Entities live in the index nerthus.entities.md (and its overflow siblings, below). The index is split: ## Gracze and ## Postacie Graczy are 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). 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 @lokacja lines above); evaluation rules are in the tag schema reference.
  • The tag list is flat. Every bullet is a - @key: value line, 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 roster sections, it carries them 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 ## Gracze / ## Postacie Graczy sections 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.

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 @zawiera convenience is retired from the closed schema; a leftover @zawiera line is an UnknownTag integrity error like any other unrecognized key.
  • A Gracz's content triggers are the typed tag @tematy_zastrzeżone (projected as RestrictedTopics) — the one Gracz-block field for them (Players & characters).

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). Core fields include Name, Type, Status, Aliases, Owner, Groups, Location, Doors, Quantity, FilePath, RestrictedTopics, and SessionForms; 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.

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. Three tag families carry it today: @forma_sesyjna claims (below), the log-derived @drzwi doors, and the checkup-refreshed Mapa @url (Locations & maps owns the latter two derivations).

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.

Operator hand edits — the claim ledger

The operator channel is the index itself. nerthus.entities.md is hand-editable end to end: the durable roster sections 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: Niepewny block with reason konflikt-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 or door 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 @alias on 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 @typ edit 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 per tag key. Its tag lines are carried verbatim — order, dates, formatting. The machine appends only whole tag keys the block lacks.
  • Claims accumulate. The machine appends a (auto) claim line only for a form new this run; a form already claimed on the disk block — marked or not — is never duplicated or overwritten.
  • Doors accumulate the same way. @drzwi merges at value level with the marker and any range folded off: the machine appends an (auto) door only for a target new this run, and a range-closed door line still matches — so an operator's rejection (the range-close gesture above) is never re-appended.
  • Machine-owned keys are re-derived. @duplikat is stripped from the disk block and re-emitted fresh; the retired projection tags @mention_count, @scalono, and @nadpisanie are stripped the same way and never re-emitted.
  • Disk-only blocks are preserved verbatim, their prose runs included. One exception: a machine quarantine ghost — a block whose last undated @status is Niepewny — is dropped once the fresh build stops emitting it, i.e. once its form got claimed or resolved. Niepewny is a machine-only status with the importer as its sole writer, so the status alone identifies the ghost; hand-authoring it on a block makes that block droppable.
  • The durable sections are untouched## Gracze and ## Postacie Graczy are 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.mdTest-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 — only the first block is reachable; qualify by city
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_do references retarget, and an already-taken name is refused), Remove-NerthusEntity (soft-delete). New/Set-NerthusLocationEntity and New/Set-NerthusMapEntity are 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.