Entities¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Entities are the nouns of the world — players, characters, NPCs, groups, locations,
maps, items, and regions — every one a ### heading block in nerthus.entities.md. This page
covers reading, searching, creating, editing, renaming, and retiring them. Names are
URL-encoded in the path; the examples use the canonical
session: the character Eraster (owned by the
Gracz Stefan), Lord Tussal, and the NPC Opat Perrin.
Reads need entity.read, writes need entity.write. Every example runs against a live
daemon — see how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /entities |
Find-NerthusEntity |
entity.read |
— |
| GET | /entities/integrity |
Test-NerthusEntityIntegrity |
entity.read |
— |
| GET | /entities/links |
— | entity.read |
— |
| GET | /entities/search |
Find-NerthusEntityName |
entity.read |
— |
| GET | /entities/{name} |
Get-NerthusEntity |
entity.read |
— |
| GET | /entities/{name}/player |
— (owning Gracz of a Postać) | entity.read |
— |
| GET | /entities/{name}/history |
Get-NerthusEntityHistory |
log.read |
— |
| GET | /entities/{name}/sessions |
Get-NerthusEntitySessionProfile |
session.read |
— |
| GET | /entities/{name}/path |
Get-NerthusEntityPath |
entity.read |
— |
| GET | /entities/{name}/file |
Get-NerthusEntityFile |
entity.read |
— |
| POST | /entities |
New-NerthusEntity |
entity.write |
✓ |
| PATCH | /entities/{name} |
Set-NerthusEntity |
entity.write |
✓ |
| DELETE | /entities/{name} |
Remove-NerthusEntity |
entity.write |
✓ |
| POST | /entities/{name}/name |
Set-NerthusEntityName |
entity.write |
✓ |
| GET | /entities/{name}/lines |
Get-NerthusEntityLine |
log.read |
— |
| GET | /entities/{name}/speech |
Get-NerthusEntitySpeech |
log.read |
— |
| GET | /entities/{name}/mentions |
Find-NerthusEntityMention |
log.read |
— |
| GET | /entities/{name}/lineage |
— | log.read |
— |
| GET | /entities/{name}/timeline |
Get-NerthusEntityTimeline |
log.read |
— |
| GET | /entities/{name}/dossier |
— | log.read |
— |
| GET | /entities/{name}/locations |
Get-NerthusEntityLocation |
entity.read |
— |
| GET | /entities/{name}/relations |
Get-NerthusEntityRelation |
entity.read |
— |
| GET | /normalization/suggestions |
Find-NerthusNormalizationSuggestion |
entity.read |
— |
| POST | /normalization/aliases/accept |
Add-NerthusComputedAlias |
entity.write |
✓ |
| GET | /hygiene/orphans |
— | log.read |
— |
Cap is the required capability (— = public, no token); Write (✓) marks routes that pass the write gate; a — in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.
POST /entities/{name}/name (body { name }) renames the block heading: it keeps the old name as an @alias, retargets exact @należy_do references, and answers 409 when the new name is already taken.
The six speech routes at the bottom of the table read what an entity said in the archived
transcripts — its lines, its aggregate, the lines that name it, where it spoke, who it
spoke beside, and one merged timeline. They read the Lokalny channel and nothing else,
and they are specified with the rest of the transcript pipeline on Logs &
audit.
Lines a narrator spoke in a session they only ran are left out. A narrator voices the NPCs
under their own nick, so the index attributes an evening of innkeepers and guards to the person
running it; asked about the character, the corpus answered with the sessions they narrated on top
of the ones they played. The cast is the distinction the lore already draws — a narrator named in
@PU or @Uczestnicy played in their own session, and those lines stay.
A subject of a place kind (Lokacja, Mapa) is read as a place rather than as a voice: the
same routes answer with the lines spoken in it, whoever spoke them, narrowed by the
resolved scene heading. So GET /entities/Thuzal/speech answers how much was said in Thuzal
and by which nicks, and GET /entities/Thuzal/scenes answers the scenes played there. Every
filter, page and scene grouping behaves the same either way.
List and search entities¶
GET /v1/api/entities returns the { count, items } envelope. Filter with ?type=,
?status=, ?group=, ?location=, ?name= (substring). Here, the two characters.
Page with ?limit= and ?offset=: naming either adds total, limit and offset, where
total is what the window was cut from while count stays the number of items returned.
Naming neither answers whole, as before.
Every field resolves as of now unless ?activeOn= names an instant, and the whole-corpus
answer is held against the model generation rather than re-derived per request — so the read
costs what its payload costs rather than what the corpus is. ?activeOn= in the past asks a
different question and is answered by walking the model, which is slower by design.
Response 200 (trimmed):
{ "count": 2, "items": [
{ "Name": "Lord Tussal", "Type": "Postać", "Owner": "Roman" },
{ "Name": "Eraster", "Type": "Postać", "Owner": "Stefan" }
] }
The whole link index, once per generation¶
GET /v1/api/entities/links answers every block in the index as
{ n, t, p, s } — the heading, the current @typ, the current @plik or null, and the other
spellings the block answers to (@nazwa_nerthus, @alias, @nick, the heading never repeated).
?withFile=true keeps only the blocks that carry a file.
It exists because marking the entity names on one lore page needs a target per distinct name on
it, and GET /entities/{name}/file answers one name per request. On the campaign corpus that is a
median of six requests per page and 1046 on the widest. The whole index is smaller than the
round trips: on the campaign corpus at 9d9fa91e, 3548 rows, 252.7 kB raw and 33.9 kB on the
wire gzipped; with ?withFile=true, 819 rows, 76.5 kB raw and 11.2 kB gzipped. The byte
figures move with the corpus and the row counts move with it too — what does not move is that
one row is one entity block, so count is the number of blocks the model holds.
The answer is keyed by ModelETag, so a client that has it revalidates with If-None-Match and
takes a 304. Both @typ and @plik are temporal, so ?activeOn= moves the answer and the
cache is keyed by the day as well as the generation.
Two blocks may share a heading — 183 Lokacja in this corpus have a Mapa of the same place
name — and both appear, distinguished by t. A consumer keying this index by n alone silently
keeps one of each pair.
Search by any name an entity answers to¶
GET /v1/api/entities/search?q= searches every surface token the name index holds — a
heading, an @alias, a slug, a @generyczne_nazwy value, or the bare leaf of a qualified
heading. Where GET /entities?name= matches a substring of the canonical name alone, this
one answers a half-remembered name: the resolver runs first, so an exact, declined,
alternated, or fuzzy hit leads the list, then every token holding q as a substring
contributes its owners. ?type= scopes the search to one entity type and ?limit=
bounds the PAGE — 50 by default, 200 at most, and a larger value is answered with 200
rather than refused. The ceiling is there because a row costs a projection: uncapped, a
limit of five thousand held a reader thread for fourteen seconds. Each item is the ordinary
projection plus MatchedToken and MatchStage, and query echoes the term that was
searched.
The ceiling bounds the page and ?offset= reaches past it. total is how many entities
matched, count is how many are on this page, and truncated says the two disagree — so a
page of 200 out of three thousand cannot be read as there are 200, which is what a bare
count at the ceiling said. offset past the end is an empty page with the same total.
?name= supplies the term as well: ?q= wins when both are present, and the 400 fires
only when neither is named. The matching stays this route's either way, so ?name=Eraster
here searches every surface token rather than the canonical-name substring GET /entities
compares.
Response 200 (trimmed):
{ "query": "Eraster", "count": 1, "total": 1, "offset": 0, "limit": 50, "truncated": false,
"items": [ { "Name": "Eraster", "Type": "Postać", "MatchedToken": "Eraster", "MatchStage": "exact" } ] }
Fetch one entity¶
GET /v1/api/entities/{name} resolves the name and returns the full projection. An
entity whose block carries @ref additionally carries the provenance fields
RefFile and RefForm — and RefBroken with RefBrokenReason when the reference
fails (tag schema); the end-to-end sequence at the foot of this
page shows them live.
Response 200 (trimmed):
The owning player of a character¶
GET /v1/api/entities/{name}/player returns the Gracz who owns a Postać.
Response 200 (trimmed):
Sessions an entity took part in¶
GET /v1/api/entities/{name}/sessions scans parsed metadata (@PU, @Uczestnicy,
locations) for the entity — needs session.read.
Names are resolved, not compared: a block written Cielin and one written
Cielin Mykhaliss reach the same character when the lore says they are one, and entity
comes back as whatever the lore calls them. Resolution runs without fuzzy matching — a name
in an award line is a proper name, and edit-distance matching over a whole cast invents
participation nobody wrote.
logCount is the number of @Logi entries on the block. A 0 is a session played and
written up without a transcript, not a gap in the archive: there is nothing to fetch, and
the block is the whole record of that evening. A location is an entity too, so asking this
of a place answers with the sessions the lore sets there.
Response 200:
{ "entity": "Eraster", "count": 1, "sessions": [{ "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "date": "2026-07-01", "logCount": 0 }] }
Everything the corpus establishes about one name¶
GET /v1/api/entities/{name}/dossier answers with one document per entity: identity and tags as of a date, the sessions it took part in, its speech profile, where it spoke, its mentions grouped by year, and the ranked passages that name it. Needs log.read, because the passages and the mention groups are transcript content.
| Section | Holds |
|---|---|
Identity |
the projection as of ?asOf=: name, type, status, tags |
Sessions |
one row per participation edge — the header, its date, and the tier |
Speech |
line and word counts, first and last spoken date, the nicks it spoke under |
Places |
one row per location segment it spoke in |
Mentions |
total, then years[] — each year's count and its first five lines, with their spans |
Passages |
searched[] naming the tiers, and items[] of ranked hits |
Every claim carries the session it came from. A dossier that asserted without attribution would be a wiki page rather than a reading of the corpus.
?asOf= reads the corpus as of a date: the projection resolves its temporal tags there, the sessions stop at it, and so do the mentions and passages. That is the question a narrator preparing a session set in the past actually has.
Two answers to read carefully. Passages.searched is empty on a host that builds no search index, which is a different fact from a corpus that says nothing about the entity. And a name the model does not hold and the corpus never says is a 404 — the speech layer answers an unplaceable name as its own subject, which is right for a nick with no lore block and would otherwise answer a typo with a complete document about nobody.
It never adjudicates. Two rows that disagree are two rows; flagging the contradiction is a precision problem with no eval set behind it.
Where a name comes from — lineage¶
GET /v1/api/entities/{name}/lineage answers where one block's name first appears in the
transcripts and what the format-11 lifecycle events say about its memberships — the computed read
behind rulings H126.5 and H127.8 (126-ner-next, 127-lifecycle-extraction, 2026-09-06). Nothing
here is written; the write is the lineage autopilot below.
| Field | Holds |
|---|---|
origin |
the first session and line the name is spoken (how: spoke, from the archive's resolved speaker) or mentioned (how: mentioned, from a sidecar mention the annotation attributes to this block) — or null when the corpus never names it |
originProposal |
the same first line as a candidate - @pochodzenie: write, with its guard counts and state |
memberships |
for a person: every group the events read it as a member of («w liczbie pięciu, czyli Khidell, Fenrod…»), the group linked through the unit's own form or its scope |
members |
for a body: the people read into it, and the extracted-from surfaces the index does not hold that later speak under their own name |
lore |
what the block already says: pochodzenie (with its (auto) flag) and every grupa value told apart as organisation (names a Grupa block) or tier (the importer's Heroic tier) |
store, guard |
whether a sample store is readable here, and the guard the autopilot applies (two sessions, two logs) |
Every membership carries its witnesses — session, log, line and the text — and a state:
candidate (would be written), thin (below the guard, with sessions and logs saying by how
much), lore (the block already carries it), ruled (a person's confirmed row on a quoted line
names another referent or none). 404 for a name the index does not hold.
Response 200 (trimmed):
{ "name": "Eraster", "type": "Postać",
"origin": { "session": "2026-07-05, Elfka w Werbinie, Roman", "date": "2026-07-05", "how": "spoke", "text": "Elfka patrzy na drzwi." },
"memberships": [ { "kind": "member-of", "group": "Unia Magii", "groupType": "Grupa", "sessions": 2, "logs": 2, "guard": true, "state": "candidate",
"witnesses": [ { "session": "…", "log": "…", "text": "Unia Magii w liczbie dwóch, czyli Eraster i Fenrod." } ] } ],
"members": [], "lore": { "pochodzenie": null, "grupa": [] }, "store": false, "guard": { "sessions": 2, "logs": 2 } }
The lineage autopilot¶
Since rulings H126.5 and H127.8 (2026-09-06) a membership or an origin the events prove lands in the
lore without a person, on a bounded tick beside the alias autopilot: every
lineage_autopilot.interval_minutes (60) it writes at most lineage_autopilot.max_per_tick (10)
lines, each under the actor autopilot:lineage in the audit stream and each marked (auto).
Three kinds: - @grupa: <Grupa> (auto) on a member's block; - @pochodzenie: <session header> (auto)
on a block whose origin was unknown; and a new NPC block, with both, for a member surface the
index does not hold that later carries lines of its own (extracted-from). Each write also
deposits one proposed entity-span row per witness line in the sample store, carrying
fields.lineage, whether or not a lore line follows.
The guard. A line lands only when the reading stands on at least two sessions AND two logs,
counted from the corpus alone — never from a row the tick is about to write or a line it wrote
before — and every write quotes the lines it stands on. A person's confirmed entity-span row on a
quoted line (POST /sessions/{header}/bindings) naming another referent or none outranks the
count: the candidate is skipped as ruled, and a line this daemon had written for it is taken back
by the next tick. A hand-written line — the same value without the marker — is never touched.
What never happens. The production lore is refused by name (repozytorium-fabularne) before
any other gate and no configuration admits it; the tick writes only into a checkout whose origin
is on lineage_autopilot.lore_projects (default: the dev lore, repozytorium-fabularne-prodkopia).
A block is never created from one session. A @grupa value that is a tier is never confused with
an organisation: the value shape decides, and only a Grupa block is ever written.
Auto-merge. The sync tick commits the lines on a vm/* branch as it does every write; a batch
that carries lineage lines is then asked to merge when its pipeline succeeds
(lineage_autopilot.auto_merge, default true) — the merge authority the ruling grants for
lineage writes on the dev lore, and nothing wider. Every tick, held or not, leaves one
operational.jsonl record (comp: lineage-autopilot with ran, reason, considered,
written, reverted, storeRows, skipped), and a held tick names its gate: Off, ReadOnly,
NoPublish, Refused:production (…), Refused:<project> is not on lineage_autopilot.lore_projects,
Empty.
Names with no file, and files with no name¶
GET /v1/api/hygiene/orphans answers with two lists, both derived from the annotation sidecars over the transcript archive. Needs log.read: a distillation of the archive is still the archive.
| Field | Holds |
|---|---|
Unowned |
tagged names that reach no entity, ranked by how often they are said, with the transcripts they appear in |
Unmentioned |
Postać, NPC, Grupa and Lokacja entities the corpus never names |
Stale |
transcripts whose annotation describes text that has since moved |
Coverage |
annotated against transcripts — how much of the archive the answer was computed over |
Coverage is not decoration. "Nothing mentions this entity" computed over two thirds of the archive is a different claim from the same sentence over all of it.
An entity that appears in a session's participation is never Unmentioned, whether or not anybody said its name aloud — otherwise every quiet NPC would be reported as dead weight. Resolution is declension-aware and never fuzzy: fuzzy matching here would hide exactly the unplaced names the list exists to surface, by attaching each to the nearest existing entity.
The first call is expensive and the rest are not. The walk opens every sidecar in the archive and resolves every distinct tagged span, and it derives the same answer until something commits — so it is computed once per tree and held. On a 1174-transcript clone the first read costs ~6.8 s and the next ones 4–15 ms; before it was held, three consecutive reads cost 8.87 s, 8.20 s and 8.38 s, and each one held the whole host for that long. ?limit= slices a list that was already built, so varying it is free and does not re-walk anything.
It produces a queue and never decides. Three sources of noise are expected: the tagger capitalizing a common noun mid-sentence, item names, and nicks the alias index resolves although the name index does not.
The derived path of an entity¶
GET /v1/api/entities/{name}/path answers the directory-style path derived from the
entity's type and its containment chain. The path is presentation and the name is
identity, so a path moves while references keep resolving — the derivation itself is
the entity model's.
This is the logical path. GET /entities/{name}/file beside it answers the physical one — the @plik target on disk, and Resolved: false where the entity has none. It is specified with the other two lore-file routes on Lore files.
| Parameter | Effect |
|---|---|
?activeOn= |
resolves the type and the chain as of this instant; now by default |
?type= |
picks between blocks sharing a name, as PATCH /entities/{name} does |
| Field | Holds |
|---|---|
Name |
the canonical name the request resolved to |
Type |
the type as of the resolution instant |
ActiveOn |
that instant, as YYYY-MM-DD |
Primary |
the one path a client shows |
Paths |
every derived path, Primary first |
Segments |
Primary split on / |
Shared |
true when Paths holds more than one |
The first segments come from the resolved type — Postaci/Gracze for a Postać,
Postaci/NPC for an NPC, Lokacje/Mapy for a Mapa, Inne for a block that resolves no
@typ. A Postać then hangs under its owning Gracz through @należy_do, which is why
Eraster reads under Stefan; a character sharing its player's name skips that segment
rather than repeating it. A place recorded under several exteriors in its @forma_sesyjna
answers with one path per exterior and Shared: true. An unknown name is a 404.
Response 200:
{ "Name": "Eraster", "Type": "Postać", "ActiveOn": "2026-07-30",
"Primary": "Postaci/Gracze/Stefan/Eraster", "Paths": ["Postaci/Gracze/Stefan/Eraster"],
"Segments": ["Postaci", "Gracze", "Stefan", "Eraster"], "Shared": false }
The audit history of an entity¶
GET /v1/api/entities/{name}/history returns audit lines touching the entity — needs
log.read.
Response 200:
Integrity check¶
GET /v1/api/entities/integrity reports schema findings across all entity blocks.
Response 200:
The findings list is open, and a finding carries Entity, Issue, Severity and — where the kind has one — Tag, Source and Detail. These kinds come from the captured game changelog rather than from the schema, and a host with no works tree, or one whose capture has not been derived, returns none of them:
Issue |
Severity | What it says |
|---|---|---|
MapaWycofanaWGrze:<data> |
Warning |
the game withdrew this place on that date, and the block still reads @status: Aktywny |
MapaSzataZmieniona:<data> |
Info |
the game repainted this place on that date, so any measurement older than it is stale |
NpcLokacjaSprzeczna:<data> |
Warning |
the log places this NPC somewhere the block's @lokacja contradicts — both places sit in the tree and neither contains the other |
NpcLokacjaNierozstrzygalna:<data> |
Info |
the two places differ and neither carries a parent, so containment cannot be decided |
NpcLokacjaSpozaKorpusu:<data> |
Info |
the log places this NPC somewhere this corpus does not model at all |
NpcLokacjaNieznana:<data> |
Info |
the log places this NPC and the block carries no @lokacja — 200 of 583 NPC blocks do not |
Detail carries both readings and the entry line they came from, and Source names the post: post:46251655.
The list above is the one the service declares, and a test compares the two — a page that enumerates wire values and is checked against nothing drifts, and this one did before the check existed.
Containment is not disagreement, and an undecidable comparison is not one either. A block saying Baraki Ithan where the log says Ithan is the more precise of the two, so the join walks @lokacja upward on both sides before it calls anything a conflict. Where neither side has a parent there is nothing to walk and the row is NpcLokacjaNierozstrzygalna — which is the common case rather than a rarity, because 2 340 of 2 370 Mapa blocks carry no @lokacja. An exact-string comparison over the same data reports more disagreements than this route does, and the extra ones are not findings.
MapaSzataZmieniona is a floor and the denominator is published with it. The corpus carries 100 lines naming an artwork change; the reader recognises 63 of them, of which 62 resolve to a block. The 37 it does not are 21 lines that say szatę graficzną kompleksu lokacji <X> or kompleksu jaskiń <X> — in scope and not yet read, one of them genitive — plus 16 naming items, monsters and clans, which are out of scope. One further shape sits outside that count altogether: Odświeżono graficznie mapy: <A>, <B>, …, one line naming six places, which states the same fact in a form nothing here models. Anything ordering a measurement pass off this list is ordering off roughly three quarters of what the corpus says.
A name the store does not hold produces nothing. The changelog covers the whole game and most of it was never written into this corpus, so an unresolved name is the ordinary case. Resolution is exact and alias only — the resolver's edit-distance stage forgives a narrator's typo and, over an external corpus, invents identities instead: measured on this one, Uzdrowicielka Emanilia resolved to Uzdrowicielka Halfinia.
These are findings and never repairs. Nothing here writes to the lore repository, and @lokacja is a temporal scalar the importer freezes after its first value — so the source that could date a succession feeds a tag that cannot hold one.
Preview a create without writing¶
?dryRun=true returns 200 { wouldCreate } and touches no disk — proven by the
follow-up 404. The scratch entity is a servant in Tussal's residence.
Response 200:
Create an entity¶
POST /v1/api/entities with a (name, type) — the pair must be unique. Type must be one
of the eight in the schema.
Response 201:
Rejected: duplicate¶
The same (name, type) twice is 409 — a duplicate heading would shadow the first.
Response 409:
{ "error": "a 'NPC' named 'Sługa Tussala' already exists — duplicate headings shadow each other; qualify the name (e.g. 'Sługa Tussala (Miasto)')" }
Fetch the created entity¶
Response 200 (trimmed):
Update a tag¶
PATCH /v1/api/entities/{name} sets one tag from the closed schema. Legacy ASCII
spellings canonicalize; an unknown tag is 422. The RefTargetProperty refusal
(the sequence at the foot of this page) applies only to entities carrying @ref
and only to keys the replacement split routes to the referenced file — this
example PATCHes @lokacja on an entity without @ref and succeeds, and
index-side keys keep working on referenced entities too.
?type= selects between blocks sharing a name. A name only one type carries needs no
selector; a name two types carry — a Lokacja and the Mapa depicting it — answers 409
EntityTypeAmbiguous with the candidate types, since writing into the wrong block of a
homonym pair corrupts quietly. The typed routes /locations/{name}, /maps/{name}, and
/regions/{name} supply their own type and never need it.
Response 200:
Rejected: unknown type¶
A type outside the closed schema is 422, nothing written. Here a real entity name with a
deliberately invalid type.
Response 422:
Rename an entity¶
POST /v1/api/entities/{name}/name renames the heading, keeps the old name as @alias,
and retargets @należy_do references.
Response 200:
Retire an entity (soft delete)¶
DELETE /v1/api/entities/{name} marks the block @status: Usunięty — never physical
removal. It takes the same ?type= selector as the tag update above, and refuses an
unqualified request on a name two types carry.
Response 200:
Referenced properties (@ref) end to end¶
@ref is an ordinary schema tag: adopting it is a plain PATCH, and the model then
reads the entity's operator-authored properties from the referenced file (the file
shapes and the per-key split are the entity model's).
The three steps below are order-dependent: adopt, read the merged view, then meet the
write policy. The value uses the @plik path convention — leading /, forward
slashes. Vinkor's fixture file opens with a leading property list, so no other step
is needed between the PATCH and the GET: the model self-heals per request.
Response 200:
The next GET serves the merged view: Groups comes from the referenced file's
leading list, and the provenance fields name the file and the shape that supplied it.
Response 200 (trimmed):
{ "Name": "Vinkor", "Type": "NPC", "Groups": ["Gildia Kupców"],
"RefFile": "/Postaci/NPC/Karka-han/Gildia Kupców/Vinkor.md", "RefForm": "2.1" }
A PATCH targeting a key the split routes to the referenced file is refused with the
stable id RefTargetProperty: the property is edited by editing the named file. A
broken reference surfaces the same way on GET /v1/api/entities/integrity, as an
Error row with Tag: "ref" and an Issue of RefBroken:<Reason>:<path>.
Response 422:
{ "error": "RefTargetProperty", "entity": "Vinkor", "tag": "lokacja",
"file": "/Postaci/NPC/Karka-han/Gildia Kupców/Vinkor.md",
"detail": "'@lokacja' of 'Vinkor' is read from '/Postaci/NPC/Karka-han/Gildia Kupców/Vinkor.md' — edit the property list at the top of that file" }
The normalization worklist¶
GET /v1/api/normalization/suggestions reports every form the corpus writes that the index
does not record, across seven surfaces:
surface |
What it finds | Evidence |
|---|---|---|
alias |
A character is called something no @alias carries |
Four ordered sieves (below) |
speaker |
A nick in the archive reaches no block | The speech roster, minus narration |
narrator |
Who ran an evening, and whether the record says so | The name engine, the header grammar, and the log-saver's transcripts |
autor |
Who a narrator is, and who filed an evening | Commit authorship, joined to the narrator vocabulary |
render |
One map render serves blocks that disagree about @lokacja |
The model alone |
wzmianka |
A name the corpus talks about reaches no block | The annotation tree's entity spans |
claim |
The @forma_sesyjna ledger's own findings |
/claims/lint |
Find-NerthusNormalizationSuggestion -Surface takes the same seven names, one per call.
narrator answers one question — who ran this evening, and does the record say so — from three
kinds of evidence, so its rows carry seven sieves:
sieve |
What fired | Line to paste |
|---|---|---|
druga-forma |
An alternate spelling folds onto the same person the index already reaches | @alias |
bliska-forma |
A signature reaches no block but is within a few characters of one that exists | @alias |
brak-bloku |
A signature reaches no block and nothing resembles it | — |
fragment-tytułu |
The header's last field is prose, so the evening has no recorded narrator | — |
współprowadzenie |
One field names several people, each of whom resolves | @Narrator, one per person |
zgłaszający-w-nagłówku |
The header's narrator is also in the evening's own cast, on an addon-saved log | — |
addon-przypisuje |
The header says Rada where the addon's transcript names a player |
— |
sprzeczny-narrator joins them where a transcript and its header name two different people and
neither is Rada, and zgłaszający-niezgodny where one evening's transcripts were saved by
several people — the reporter records the first, and the row says who else there was.
A narrator identity is a Postać, Gracz or Grupa block, never an NPC: the one narrator string of 108 that reached an NPC (Kris, seven evenings in 2017–18, onto a tavern NPC created in 2022) became an (auto) alias before that type left the list on 2026-09-05. bliska-forma is separated from druga-forma deliberately: one is a spelling the engine already
folds onto a person, the other reaches nobody and is a proposal that two names are one. Its
floor is normalization.narrator_near_form_score (configuration), and it is
a floor rather than a judgement — at the shipped value the sieve keeps Soll Węglobordy against
Soll Węglobrody and refuses Rote against Velrose, which are two different people.
zgłaszający-w-nagłówku is the shape that costs an operator the most to spot unaided. The
log-saver addon prefills its template's narrator with whoever ran the saver, so on a declaration —
where the header names the reviewer, Rada by default — that prefill leaves the reporter
standing in the reviewer's field. Measured on the campaign corpus, the shape is roughly nine times
commoner on addon-saved sessions than off them, which is what makes the host part of the test
rather than noise.
autor reads the repository's commit history. A commit author names a player; a session
header's narrator field names a character. Keeping those apart is the whole of the surface. It
proposes an @alias only where one commit identity answers to exactly one block and signs exactly
one spelling that reaches none — on the campaign corpus that is Macabresca under Vanda Vanissa,
143 evenings. Where several characters share an identity the row targets the Gracz through
@należy_do, carries no line to paste, and says so: eleven characters behind one address is one
player, not one person's eleven names. A host with no .git answers no rows and names the absence
in stats.autor.error.
An identity the corpus does not name is not named here either. A row describes a person only by a
block, a player, or a narrator signature the sessions themselves carry, so a user.name out of a
git config never reaches a reader.
Who is asking¶
autor is the one surface whose answer depends on the caller, and every row on every surface
carries visibility for it:
visibility |
Means | Who reads it |
|---|---|---|
public |
The entity store already records the relation the row asserts | Anyone holding the route's entity.read |
moderation |
Only the commit history joins the two names | moderation.read as well |
Publicity follows the entity store. A pair @należy_do or @alias already carries is visible to
an entity reader anyway, so repeating it discloses nothing; a pair only git holds is a claim about
a person that the campaign never wrote down, and disclosing it is a human's act. A caller without
moderation.read receives none of the second kind, and stats is re-tallied so a hidden row is
not counted in front of them. A caller naming no capability at all receives none of them either.
The route's own Cap stays entity.read: the route is readable by everybody and only some of its
rows are not.
wzmianka is the only surface that reads who is spoken of rather than who speaks, and the two
populations barely overlap: on the campaign corpus only 23 of its top 60 forms also stand on
alias, and it holds every epithet the campaign uses. It is niska throughout and it is a queue
rather than an adjudication — the tagger capitalising a common noun mid-sentence lands here, and
targetType carries its own label (persName, placeName, orgName, geogName) so a reader can
tell a person from a place before opening anything. It is the same list
/hygiene/orphans reports, in the worklist's row shape.
A host with no nerthus.lang/ cannot answer this surface at all. It reports no rows and names the
absent tree in stats.wzmianka.error. The surface is a page: the orphan service is asked for 500
rows, and stats.wzmianka.population says how many it held before the cut, truncated whether
any were dropped (2026-09-05: count 500 read as a census for a week). The absent tree lands in stats.wzmianka.error:
{ "wzmianka": { "count": 0, "exact": 0, "wysoka": 0, "średnia": 0, "niska": 0,
"error": "brak drzewa anotacji (nerthus.lang) — ta powierzchnia czyta opisy transkryptów" } }
A provider that fails writes to the same field, so a caller wondering why a section is empty has one place to look, and the message is what tells a missing input from a broken provider.
speaker drops narration before it reports anything. A transcript marks speech with a colon and so
does prose, so a sentence like Zgrzyt powiedział, raczej do siebie: arrives in the speaker column
as a nick — one that reaches no block, and that no lore edit could ever make resolve. The test is the
published morphology WITH THE DICTIONARY OVER IT: morph.tsv records the part of speech the
tagger read each surface as and what SGJP says about that reading, and a speaker string counts
as a sentence only where a word of it is read as a verb the dictionary also admits. The order
matters and it is not a tuning: read without the dictionary, one VERB reading anywhere made a
surface a verb everywhere, and 115 NPC names carrying 1 951 speech lines left this surface —
Cielin Mykhaliss 681 lines among them. Over a tree with no
morphology in it the test falls back to the lemma dictionary — a Polish verb lemma is the
infinitive, an infinitive ends in -ć — which reaches 96.9 % of the surfaces the reading reaches
and mistakes 8.5 % of what it flags, karl → karlić among them. On the campaign corpus that is 361 of 1 030 rows, a
third of the surface. Without a dictionary the test is silent and the surface is what it was. The
parse folds a one-off narration segment out of the speaker column before the archive is written
(the log pipeline), so this test sees what that fold leaves
behind: the prefixes that recur, and the short ones it never reconsiders.
Each row names the test that fired and how far the evidence goes. exact is an identity proof —
the form maps to one block and provably cannot map to another — and the row is an offer to persist
rather than a request to verify, because the tier already acts (see
The exact tier); wysoka is structural, a string relation or a resolver
hit, adoptable almost unread; średnia is statistical or partial, usually right, and wants a
human on the attribution; niska is a question rather than a proposal and carries no line to
paste. On the campaign corpus that is roughly 17 wysoka and 200 średnia, which is the ceiling of
what sieves reach without a language model. Adopt a row with PATCH /entities/{name} (a
multi-valued tag appends), a page of exact rows with
POST /normalization/aliases/accept, or by editing
nerthus.contributors.md and committing.
There are four levels and there will not be a fifth. A level is a producer-assigned adjective, so a
fifth invites sorting arguments no consumer can settle; granularity a level cannot carry goes into
reason, scope, and the per-surface stats block. A numeric score, if one is ever wanted, is a
new field beside confidence and never a replacement, because every consumer switches on the
adjective.
The alias sieves, most precise first, first match wins:
| Sieve | Fires on | Confidence |
|---|---|---|
wydłużenie |
A held letter run collapses onto a block's first word (Briiimm → Brimm) |
exact or wysoka |
zdrobnienie |
A Polish hypocoristic over a shared stem (Vandzia → Vanda Vanissa) |
wysoka |
odwrócenie |
The form read backwards is a block's first word (Radnerak → Karendar) |
wysoka |
skrócenie |
The form (≥3 letters) heads exactly one block's first word and is written in two logs (Tae → Taenya Lavrec, Vel → Velrose); a head several blocks share is a contest |
wysoka |
inicjały |
An all-caps form (2–5 letters) equal to the initials of exactly one Grupa block (ZPA → Zakon Planu Astralnego) — organisations only, by ruling 2026-09-05 |
wysoka |
współobecność |
NPMI over the scenes a form and a speaker share; never for a form that signs its own lines — a speaker nick with no block is a person, not another person's alias (27 of 220 such rows on 2026-09-05 were NPCs bound to co-present players) | średnia |
Over all four sits the presence prior: support is the share of the sessions a form was
written on that the chosen block actually attended, read off
the participation graph. It claims nothing on its own. It
settles a contest, and it vetoes a hit.
A contest is possible for zdrobnienie alone, because its relation is a prefix while the other two
string sieves compare folded words for equality. Harumcia strips to the stem harum, and two
Postać blocks start with it — on the campaign corpus 28 of 194 Postać blocks share a diminutive
base with some other block, so a seventh of that sieve's surface is a choice rather than a relation.
support makes the choice: the winner keeps wysoka and rivals carries the blocks it beat, and
a contest the prior cannot settle — a tie, or a field where no block has a roster at all — is
średnia with rivals populated, because a pick nothing distinguishes is not adoptable unread.
The veto is the other direction. A hit whose block attended none of the form's own sessions
falls to niska rather than being dropped: a narrator writing about an absent character is a real
pattern, and it belongs at the bottom of the worklist rather than off it. Two things it stays quiet
about, and the distinction matters. A block with no roster — a Mapa, a Lokacja, most NPC —
answers support: null rather than 0, since it has no attendance to be absent from; and a form
written on fewer than three sessions is not vetoed at all, because absence from one evening is not
a pattern.
The exact tier¶
exact is an identity proof, not a high score. A producer may emit it only when the claim is
injective on the evidence: the form maps to one block and provably cannot map to another. Two
producers reach it today.
| Producer | What has to hold |
|---|---|
wydłużenie |
The collapsed form equals exactly one block's first word, and that block attended some of the sessions the form was written on |
druga-forma |
The narrator's alternate spelling appears against no other block anywhere in the index |
zdrobnienie never reaches it: its relation is a prefix, which is why it has a contest at all.
współobecność never reaches it: it is statistical by definition. And membership in a conflicted
stem group is a veto whatever the producer said — the same name in another case reaching a
different block means at most one member of the group is right, so none of them is injective. The
veto is applied centrally, once, after every provider has answered, which is also where the
provenance cap lives: evidence whose authority is below rada or transcript counts as attestation
and caps the row at średnia. A capped row says so in capped (konflikt or autorytet).
Rows carry scope. global is injective everywhere. session is injective only given a cast —
four blocks answer to Elfka on the campaign corpus (Taenya Lavrec, Riveth, Aislin Ragain, Aurora)
and different evenings have different ones — so a session-scoped claim resolves only where exactly
one of its candidates is in the session's cast and abstains on two. Session-scoped rows never enter the resolver and are never written as an @alias.
They are harvested rather than curated: a form the corpus writes lowercase as well as capitalised,
capitalises mid-sentence, and that two or more blocks both share scenes with and reply to. On the
campaign corpus that is 50 forms over 56 blocks.
The tier acts without being persisted. Global rows are published to a derived store the daemon
keeps beside its other caches, and two consumers read it: name resolution
gains a stage at score 0.97, above grammar and below a name the lore itself wrote; and the
dashboard marks entities in transcripts and session descriptions from it with no accept at all.
A row whose visibility is moderation is excluded from that store, and the paragraph above
does not describe it. Applying such a pair would publish repo-wide, to every reader, the one thing
the capability holds back — so it is readable by a moderator, applied by nothing, and reaches the
index only through the accept route, where a person's write is the
disclosure. Macabresca is the campaign corpus's only instance.
Application without persistence is the contract — a mark can be wrong for one render, the index
cannot — so nothing scheduled ever writes lore from this tier. The store is stamped with the
archive, the model, and the annotation digest, and any of the three moving discards it, which is how
a renamed or deleted block invalidates every row naming it without any bookkeeping.
A host with no store answers exactly as it did before the tier existed. That is a test rather than a promise.
Accept computed aliases¶
POST /v1/api/normalization/aliases/accept is the only path from the tier into
nerthus.entities.md. It takes a whole page at once, which is the chore it exists to end.
{ "stamp": "638…|a41f…|9c2e…",
"aliases": [ { "form": "Macabresca", "entity": "Vanda Vanissa" },
{ "form": "Frycek", "entity": "Fredrick Flumiene" } ] }
stamp is the store the rows were read from, echoed back by every response. If the store has been
rebuilt since — a new transcript, a lore edit, a re-annotation — the whole call is refused with
409 ComputedAliasStale and nothing is written. The daemon does not re-resolve the pairs
against the new store: the operator approved the rows in front of them, not whatever those rows
became. Omit stamp to accept against whatever the daemon holds now.
Each accepted pair appends one - @alias: <form> line to its block, unmarked — and that is the
one place the marker comes off. A worklist row's line carries (auto) because it is a machine
proposal, and the entity model makes deleting that marker the
gesture for adopting one: paste, read, drop it. Accepting through this route performs the same
adoption in one step, so the line lands already adopted. Marking it would say a rebuild may replace
it, and nothing would — the sieves stop proposing a form once the index holds it.
What the file therefore does not say is that the line came from a proof rather than from somebody's
memory, and that is recorded in the audit stream instead: one entity.write record per accepted
alias, carrying the actor, the block and the form.
The alias autopilot¶
Since ruling H124.1 (124-kto-to, 2026-09-04) one population no longer waits for a person: an
exact row with no rival, no conflict and no cap, on any surface, is written by the daemon
itself as - @alias: <form> (auto) — marked, because nobody adopted it, so a rebuild may replace
it and a person may delete it. The tick runs on the serve loop every alias_autopilot.interval_minutes
(30) and writes at most alias_autopilot.max_per_tick (20) lines, through the same accept service
with the same store check; each line is one entity.write audit record under the actor
autopilot:alias, and the sync tick commits it like any other write. Five gates hold it (the fifth
since 2026-09-05): two witnesses — the row's form occurs at least twice, or in two logs — because
exact says the relation is injective in the index, not that the corpus said it twice (of the first
live tick's eight lines, one stood on a single archived span and both wrong ones had no second);
a row without them is skipped with reason: thin. The other four: the host
is ReadWrite; its sync publishes (push or mr — a replica never writes what it cannot push);
schedules.alias_autopilot.owner is fleet (Configuration); and the lore
does not already name the form, which makes a tick over a stale store a no-op rather than a
duplicate. The file alone does not close that door: the sync tick commits the lines on a vm/*
branch and puts the tree back on HEAD, so until that merge request lands the file no longer
names what the last tick wrote — measured on the dev fleet 2026-09-04, eight lines written at
20:12 and again at 20:42, one duplicate merge request per tick. The daemon therefore keeps the
forms it wrote and skips them (reason: written) until the lore names them; a restarted daemon
starts with an empty memory. Every tick, held or not, leaves one operational.jsonl record
(comp: alias-autopilot with ran, reason, considered, written, skipped), so a quiet
tick and a dead one do not look the same. Session-scoped rows — the shared epithets — are never
written; they stay the Kto to? panel's to bind per evening.
One pair failing does not fail the others. Per-row verdicts come back in items, and a refusal
names which kind it is:
reason |
Meaning |
|---|---|
NotComputed |
The store holds no row for this form |
SessionScoped |
The store holds it, scoped to a session — a shared epithet is never written as an @alias |
EntityMismatch |
The store reaches a different block; computed carries which |
EntityNotFound |
The index no longer holds that block |
MissingField |
The pair has no form or no entity |
?dryRun=true returns the same verdicts and writes nothing, so a caller can see which pairs the
store still backs before spending a commit. Add-NerthusComputedAlias -WhatIf is the client side of
that.
await fetch(
"https://evocation.nerthus.pl/v1/api/normalization/aliases/accept?dryRun=true",
{ method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ aliases: [{ form: "Macabresca", entity: "Vanda Vanissa" }] }) },
).then((r) => r.json());
Conflicts across the worklist¶
rivals says what one sieve run considered. conflict says what the whole worklist decided,
and they are different questions. A Polish nickname is written in five cases, each case is an
independent candidate, the evidence for it is split five ways, and co-presence then picks a
different block for each. On the campaign corpus 16 declension groups hold 39 alias rows that
disagree — Ermos, Ermosa, Ermosie, Ermosem and Ermosowi reach Fendetta, Karendar,
Karendar, Riv Devisson and Karendar — and at most one member of each group is right.
A conflicted row carries conflict with the group's stem, every form in it with its own target and
count, and names: every block the group reaches. The whole group rides on every member, so a
request narrowed with ?surface= still shows what a row is part of. It is reported and never
resolved — which of the five is right is a question about the campaign, and the row names the
blocks so the person who knows can answer it.
Two things the engine deliberately does not do. It never proposes a form the resolver
already reaches: Polish declines names, so Achalena is the genitive of a block that exists and
Stage 2 finds it — recording that would be asking the index
to hold the grammar of Polish. And co-presence never rises past średnia: it is about 72% accurate
at picking the right block, which is a worklist and not a write. support is reported on those rows
too, and it is a stronger argument than the co-presence score — but promotion moves rows up, so
the threshold that would justify it is being measured rather than guessed.
?surface= is worth passing. Four of the seven read the model and answer in milliseconds; alias
and wzmianka read every archived transcript and take seconds on a campaign corpus, and autor
walks the commit history. stats reports every surface's totals whatever the filters did, so a
page can show what it did not fetch, and a surface whose provider failed says so there rather than
taking the answer down.
The envelope also carries computedStamp: the state of the computed-alias store these exact rows
were derived from. Send it back to the accept route and an accept
written against a corpus that has since moved is refused rather than applied.
?session= takes a session header and keeps the rows that evening's transcripts produced. It is
what a session page asks with, and the answer echoes session back so an empty page is readable —
an evening with no findings and one whose transcripts were never fetched are both empty, and only
the echo tells them apart.
?entity= takes one name and keeps the rows whose target or subject is that name — every form
one block is written in, which is the question a per-name page asks. It echoes entity back for
the same reason session does, and more urgently: an unrecognised query parameter is dropped in
silence, so a client filtering by name against a host that predates the echo would render the
whole worklist as one name's forms, which is worse than the feature's absence. A consumer
compares the echo against what it asked and refuses to render when it is missing or differs.
A name the index does not hold is 400, matching what ?surface= and ?minConfidence=
already do. It answered an empty page before, and an empty page cannot be told apart from a typo:
a misspelled name read as a clean corpus. It composes with ?surface=, ?limit and ?offset.
GET /v1/api/normalization/suggestions?surface=alias 200 count 347
GET /v1/api/normalization/suggestions?surface=alias&entity=Riveth 200 count 20, "entity": "Riveth"
GET /v1/api/normalization/suggestions?surface=alias&entity=NieMaTakiego 400 Nieznana nazwa 'NieMaTakiego' — indeks jej nie zna
Until 2026-08, an evening whose transcripts were never fetched answered 400 rather than an
empty page, and so did every header naming no session — the two states this paragraph promises to
answer with an empty list. On the dev corpus that was 10 of 11 sampled sessions. A caller measuring
this route by wall clock alone would have seen plausible timings and been reading error bodies.
The surface pass is computed once per tree and per ?surface= set, not once per request. The
seven providers and the conflict pass depend on the committed tree and on which surfaces were asked
for; ?session=, ?entity= and ?minConfidence= only narrow what they produced. So a page opening
twelve session panels pays for one pass, not twelve: measured on a 1174-transcript clone, twelve
distinct ?session= reads cost 17.03 s in total, of which 16.88 s is the first and the other
eleven are 11–24 ms each. Before, the same eleven cost ~0.86 s apiece.
That first read is still seconds, and alias (~10 s) and wzmianka (~7.6 s) are most of it.
autor costs ~1.6 s cold and ~1.2 s warm: one git log over author names on every read, plus a
patch walk that is cached under .nerthus/ against the corpus head and runs again only for the
commits since. The other four surfaces together are under 1.5 s. ?surface= remains the way to
avoid paying for answers you are not showing.
The route is replica-safe: every row is a function of the committed archive and the committed
index, so any host holding that commit derives the same ones. autor extends what "holding" means
— it reads the commit history, so a host serving from a shallow clone or an unpacked snapshot
derives that surface empty and says so in stats.autor.error rather than differing silently. The
route is also answered on a reader thread, so a cold pass no longer blocks every other read on the
host while it runs.
One row, as the alias surface emits it:
{ "surface": "alias", "subject": "Vandzia", "target": "Vanda Vanissa",
"targetType": "Postać", "sieve": "zdrobnienie", "confidence": "wysoka",
"occurrences": 31, "logs": 9,
"firstDate": "2024-03-30", "lastDate": "2025-12-24",
"examples": ["pastebin.com_raw_nWysK8rX_EDEA55F709B2D164"],
"line": "- @alias: Vandzia (auto)",
"support": 1, "supportSessions": 6, "rivals": [],
"reason": "'Vandzia' to zdrobnienie na '-zia' od rdzenia 'vand' — forma pada 31 razy w 9 transkryptach i nie trafia dziś w żaden blok" }
supportSessions is the denominator support is a share of — the sessions the form was written on,
not the sessions the block was in — so a row reads as 6/6. A contested row carries the field the
surface used to have no way to report:
{ "surface": "alias", "subject": "Harumcia", "target": "Harumi Riko",
"targetType": "Postać", "sieve": "zdrobnienie", "confidence": "wysoka",
"occurrences": 82, "logs": 41,
"support": 1, "supportSessions": 41,
"rivals": [{ "name": "Harume", "type": "Postać", "support": 0 }],
"line": "- @alias: Harumcia (auto)",
"reason": "'Harumcia' to zdrobnienie na '-cia' od rdzenia 'harum' — wygrywa obecnością 100% wobec 'Harume' — forma pada 82 razy w 41 transkryptach i nie trafia dziś w żaden blok" }