Lore files¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Every other read in this daemon answers from the model — a projection, a session record, a speech index. None of them answers "show me the file that says who this is". These three routes do: they serve the lore tree as it sits on disk, one directory level or one file section at a time. The model behind them is The lore-file read surface.
All three need entity.read — and a file's session blocks need session.read on top, see below. All three are replica-safe and poolable, and none of them writes. Names are URL-encoded in the path; the examples use the canonical session: the character Eraster, defined in Postaci/Gracze/Eraster.md.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /files |
Get-NerthusFileTree |
entity.read |
— |
| GET | /files/content |
Get-NerthusFileContent |
entity.read |
— |
| GET | /entities/{name}/file |
Get-NerthusEntityFile |
entity.read |
— |
Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes, revalidation — is on the API reference index.
Reachability is an allowlist¶
A caller sees the eight roots below, plus seven root-level files, and nothing else. It is an allowlist rather than a filter because a read API that can be talked into .nerthus/state/ hands out the grant ledger, and a blocklist is one forgotten directory away from doing that.
| Root | Session source | Generated |
|---|---|---|
Świat gry |
✓ | — |
Postaci |
✓ | — |
Organizacje |
✓ | — |
Wątki |
✓ | — |
Bestiariusz |
— | — |
Archiwum |
— | — |
Źródła |
— | — |
Lokacje |
— | ✓ |
The root-level files are nerthus.entities.md, nerthus.contributors.md, nerthus.ledger.md, nerthus.maps.md, nerthus.pu-sessions.md, README.md and REPOZYTORIUM.md. The two data tables are listed on the same argument the generated session registry is: nerthus.pu-sessions.md is rewritten wholesale by the monthly settlement, and a reader who cannot find it goes looking — finding it outside the explorer is how a hand edit gets made and then reverted. It is a choke file, so its content read refuses; listing it is what is safe.
A ninth root, Dzieła, appears for a caller holding external.read: the works repository, a second working tree named by corpora.dziel (Configuration). It resolves against that tree rather than the lore root, and containment is measured there — a path that normalizes outside it is refused exactly as one that leaves the lore root is. Inside it, every source directory is content by construction, so there is no second allowlist; the two exceptions are .git and the gitignored .nerthus/, which are not listed and cannot be read.
Without the capability the root is not listed, and a path under it is refused as 404 NotInTree — the same answer any unknown path gets. Hidden rather than listed-and-refused, because a root a caller can see and cannot open still discloses that the corpus exists, which is the one thing a private work's owner was promised it would not do. A host with no works tree configured does not list it either, for anybody.
Lokacje is the generated session registry. It is listed rather than hidden: a reader who cannot find Sesje.md goes looking for it, and finding it outside the explorer is how a hand edit gets made and then overwritten.
nerthus.logs/ is excluded. A transcript has its own route with its own projection on Logs & audit, and a directory of sidecars is not a file tree anybody pages through.
Text is served for .md, .txt, .json, .yml, .yaml and .csv. Anything else is listed with its size and refused as content with 415 NotText.
Refusals¶
One refusal map covers all three routes, so a caller learns the same thing from the same id whichever one it asked.
| Error | Status | Means |
|---|---|---|
PathRequired |
400 | GET /files/content with no ?path= |
AbsolutePath |
400 | a drive-rooted path (C:\…) |
EscapesRepo |
400 | a ../ that normalizes outside the repository root |
NotADirectory |
400 | /files was asked for a file — read it with /files/content |
IsADirectory |
400 | /files/content was asked for a directory — list it with /files |
NotFound |
404 | no such path in the lore tree |
NoSuchSection |
404 | the file carries no section by that name |
NotText |
415 | the extension is not served as text; the body carries bytes |
A path outside the allowlist is a 404, never a 403. Whether .nerthus/state/ exists is not something an entity.read caller should be able to confirm.
A leading slash is accepted and stripped, because @plik is written that way — /Postaci/Gracze/Eraster.md — and a caller passing the value it was handed should not have to know that. The consequence is that a POSIX absolute path reads as repo-relative and the allowlist refuses it as NotFound rather than as AbsolutePath. Refused either way, and the same 404 either way, since whether /etc/ exists is not something this route confirms.
List one level of the tree¶
GET /v1/api/files answers the { path, count, items } envelope for one directory level. An absent or empty ?path= lists the synthetic root — the allowlist itself, so a caller never has to know it.
Each item is { name, path, kind, bytes, sessions, generated, sessionSource, text, authority, diegesis }. Directories come first, then files, both ordinal, so two hosts holding one commit list a directory identically whatever their filesystems think.
One level rather than one manifest: a whole-corpus tree is a payload nobody reads in order to open two directories. sessions counts the session blocks in a text file, and is null for a directory — and null for anything under Dzieła, where the session heading grammar does not apply and a badge that was always zero would be noise.
authority and diegesis are the same two provenance axes a search hit carries, and Search owns what the values mean. In the lore tree they are constant per root; under Dzieła they come from the source's INDEX.md, and a per-file override there is rendered as it is written rather than as the source's default. Both are empty on the Dzieła root entry itself, because it spans sources that declare different values and neither the first source's nor an average would be true.
Response 200 (trimmed):
{ "path": "", "count": 3, "items": [
{ "name": "Świat gry", "path": "Świat gry", "kind": "dir", "bytes": 0,
"sessions": null, "generated": false, "sessionSource": true, "text": false,
"authority": "rada", "diegesis": "in-game" },
{ "name": "Postaci", "path": "Postaci", "kind": "dir", "bytes": 0,
"sessions": null, "generated": false, "sessionSource": true, "text": false,
"authority": "rada", "diegesis": "in-game" },
{ "name": "Bestiariusz", "path": "Bestiariusz", "kind": "dir", "bytes": 0,
"sessions": null, "generated": false, "sessionSource": false, "text": false,
"authority": "rada", "diegesis": "in-game" }
] }
Naming a directory inside a root lists that level:
Asking /files for a file is a 400, with the id naming the route that would have worked:
Read a file, or one section of it¶
GET /v1/api/files/content?path= answers { path, section, text, bytes, totalLines, contentHash, generated, headLines, sessionCount }, plus sections where the index is the answer or negligible beside it.
A lore file is prose then sessions, with [[_TOC_]] between them, so a section is the unit of a read:
?section= |
Answers |
|---|---|
| absent | the whole file, plus the section index |
head |
everything before the first session heading — the sheet |
sessions |
the block headings and their line ranges, no bodies |
| a session header | one block, byte-exact between its heading and the next |
Handing a five-thousand-line file to a reader who wants the character sheet is the mistake this parameter undoes. On the campaign corpus a character diary runs to 352 kB whole against 2.5 kB for its head.
Session blocks cost session.read¶
A session record is a block inside a lore file, so this route is a second door onto what /sessions/{header} serves. Both are gated the same way: a caller without a full session.read grant gets head and nothing else, and every other section answers 403 SessionsRestricted.
?section= |
Without session.read |
|---|---|
absent, all |
403 SessionsRestricted |
head |
served, without the sections index |
sessions |
403 SessionsRestricted |
| a session header | 403 SessionsRestricted |
A file carrying no session block is unaffected, whatever the caller holds — which is most of the tree. The gate is narrow on purpose: prose is lore and stays on entity.read.
The refusal is a refusal rather than a truncation. Stripping the blocks and answering the rest would hand back bytes under the whole file's contentHash, and a later write matching that hash would then be written against a document the caller never saw.
contentHash is over the whole file whatever the section, because it is the token a later write must match: a hash of a fragment would let two edits to different sections of one file both believe they were current. It hashes the bytes rather than going through Nerthus.ContentHasher, which strips whitespace — right for asking "is this the same lore", wrong for asking "is this the same file".
Response 200 (trimmed):
{ "path": "Postaci/Gracze/Eraster.md", "section": null,
"text": "**Stan:** Zdrowy.", "bytes": 17, "totalLines": 1,
"contentHash": "sha256:…", "generated": false,
"headLines": 1, "sessionCount": 0,
"authority": "rada", "diegesis": "in-game" }
A content read with no target is a mistake rather than a default, so it is a 400 and not a listing:
A path the allowlist does not cover is a 404, whether it exists on the host or not:
An EscapesRepo refusal is the 400 case: a ../ chain that normalizes outside the root is rejected by containment, before the allowlist runs.
Find where an entity is defined¶
GET /v1/api/entities/{name}/file answers { Name, Type, Resolved, Path, Kind, Generated } — the @plik target, which is what a reader following a nick actually wants. ?type= disambiguates a name two types answer to, and ?activeOn= resolves it as of an instant.
The target differs by type in a way a client must not guess at: a file for Postać, NPC and Grupa, a directory for Lokacja. Kind says which.
Response 200:
{ "Name": "Eraster", "Type": "Postać", "Resolved": true,
"Path": "Postaci/Gracze/Eraster.md", "Kind": "file", "Generated": false }
An entity with no @plik answers 200 with Resolved: false, not 404. Most Postać and NPC entities carry one; most Lokacja do not, and no Mapa does. Those entities are defined in nerthus.entities.md itself, and a client is told to show the block there. A 404 would make "this type has no file" indistinguishable from "this name is not an entity" — which is the answer a genuinely unknown name gets.
Cmdlets¶
Get-NerthusFileTree -Path 'Postaci'
Get-NerthusFileContent -Path 'Postaci/Gracze/Eraster.md' -Section head
Get-NerthusEntityFile -Name 'Eraster'
See also¶
- The lore-file read surface — the model behind these routes
- Entities —
GET /entities/{name}/path, the logical path beside this physical one - Logs & audit — transcripts, which these routes deliberately do not serve
- Entity model — the
@plikconvention itself