The lore-file read surface¶
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 Nerthus.Core answers from the model: a projection, a session record, a speech index. A reader following a nick or a mention is often asking a different question — "show me the file that says who this is" — and no projection answers it. @plik names a file and GET /entities/{name}/path names a logical path, but until these routes existed nothing served a byte.
This page is the model. The wire contract is Lore files, and Architecture owns the daemon and thin-client contract around it.
Concepts¶
A lore file has two halves. Prose first, then session blocks, with [[_TOC_]] between them. On the campaign corpus there are 1411 Markdown files: 476 carry session blocks and 935 are prose alone, and the largest runs to 5496 lines. So the unit of a read is a section, not a file.
A section read is not an optimization, it is the contract. head is the character sheet, sessions is the block headings, one header is one evening. Handing a five-thousand-line file to a reader who wants the sheet is the same mistake GET /sessions/summary exists to undo.
Nothing here writes. The service reads; no path in it opens a file for writing.
Reachability is an allowlist¶
The gate is Test-NerthusFileReadable, and it runs in a fixed order.
- Containment, through
Resolve-NerthusRepoContainedPath. It refuses a drive-rooted input and any../that normalizes outside the root, and it compares against the root plus a separator so/repo-evilcannot pass for/repo. - The choke-file check. A choke file is robot-owned. Listing it is harmless; reading it invites an edit, so
Test-NerthusChokeFilerefuses it. - The allowlist — eight roots, plus seven root-level filenames at depth one.
An allowlist rather than a blocklist, 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 exactly that. .nerthus/ and nerthus.logs/ stay out without either being named.
An empty path is the tree root and is allowed. That is how a caller lists the roots without having to know the allowlist.
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. One consequence follows: a POSIX absolute path reads as repo-relative and the allowlist refuses it as NotInTree, so the wire answer is 404 rather than the 400 a drive-rooted path gets. Refused either way, and the same 404 either way, since whether /etc/ exists is not something this route confirms.
Lokacje, the generated session registry, 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. The tree marks it generated: true; Assert-NerthusNotSessionRegistryTreePath is what enforces read-only on any write path.
One level, not one manifest¶
Get-NerthusFileTreeService answers one directory level. 1411 files as a single response is a payload nobody reads in order to open two directories.
Directories come first, then files, both ordinal — the same ordering rule Get-NerthusSessionSourceFiles gives. Two hosts holding one commit therefore list a directory identically, whatever their filesystems think about case and accents. That is what lets the routes carry Replica = $true.
The root level is synthetic: the allowlist itself, filtered to the roots that exist on this checkout.
sessions is counted only for a text file, and only at a level deep enough to show files. Counting every file in a 722-file root to draw a badge is work the reader did not ask for. Measure-NerthusFileSessions does it in one pass over the lines, matching the parser's own heading shape — ### followed by an ISO date and a comma — rather than parsing the blocks.
Sections¶
Split-NerthusFileSections divides a file at the session headings. Get-NerthusFileContentService then serves what -Section asked for:
-Section |
Answers |
|---|---|
| absent | the whole file, plus the section index |
head |
everything before the first session heading |
sessions |
the block headings and their line ranges, no bodies |
| a session header | one block, byte-exact between its heading and the next |
The section index rides along only where it is the answer or negligible beside the body.
Extensions served as text are .md, .txt, .json, .yml, .yaml and .csv. Everything else is listed with its size and refused as content with 415: the corpus carries megabytes of attachments under Wątki, and none of it is text a reader wants inlined.
Two hashes, two questions¶
Get-NerthusFileByteHash is SHA-256 over the file's bytes, lowercase hex. It is deliberately not Nerthus.ContentHasher.
Nerthus.ContentHasher strips whitespace before hashing. That is right for asking "is this the same lore" and wrong for asking "is this the same file" — a concurrency check built on it would let a reflow pass for no change at all.
contentHash is over the whole file whatever section was asked for, 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.
Where an entity lives¶
Get-NerthusEntityFileService resolves the @plik target. The shape differs by type in a way a client must not guess at: a file for Postać, NPC and Grupa, a directory for Lokacja.
Coverage is uneven by design. On the campaign corpus 819 of 1124 non-Mapa entities carry a @plik — 98% of Postać, 90% of NPC, every Grupa, but only 27% of Lokacja and none of the 2370 Mapa.
So Resolved = $false is a first-class answer rather than a 404. Those entities are defined in nerthus.entities.md itself, and a client is told to show the block there. Answering 404 would make "this type has no file" indistinguishable from "this name is not an entity", and the second one is what a genuinely unknown name gets.
Why these reads are cheap¶
All three routes carry Pool = $true and Replica = $true, and all three declare an ETag.
Pool holds because the service reads the published generation and memoizes nothing onto the daemon. Replica holds because a file is a function of the commit: any host holding that commit serves the same bytes. The ETag is the model identity, so a caller whose copy is current pays a 304 instead of the read — see revalidation for the shared contract.
Cmdlet surface¶
Get-NerthusFileTree, Get-NerthusFileContent, Get-NerthusEntityFile. The route table and the response shapes are in Lore files.
See also¶
- Architecture — the daemon and thin-client contract
- Lore files — the wire contract
- Entity model — the
@plikconvention and the index these files sit beside - Session Registry — the generated
Lokacje/tree this surface lists but never writes - Logs model — transcripts, which this surface deliberately does not serve
- The dashboard — the client the Repo tab is built on