Sessions¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Sessions are the ### YYYY-MM-DD, Title, Narrator blocks that chronicle play. This page
covers listing, fetching, the participation graph, integrity, and the write workflows
(create, edit, open, close, distribute, apply transfers). The examples use the fixture
session 2026-07-01, Eraster rozmawia z Tussalem,
Anward — narrated by Anward, with the characters Eraster and Lord Tussal on
its @PU.
Reads need session.read, writes need session.write, and closing needs session.close;
distribution needs session.distribute and applying transfers needs currency.write. A player's self-service
report needs only session.report (seeded in the gracz bundle). session.read is not: a
player does not read the record of an evening, including the ones they played, and the same gate
stands on the session search tier and on the session blocks inside a
lore file. Headers are
URL-encoded in the path (,→,, space→%20, ó→%C3%B3). Every example runs against a
live daemon — see how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /sessions |
Find-NerthusSession |
session.read |
— |
| GET | /sessions/summary |
— | session.read |
— |
| GET | /sessions/integrity |
Test-NerthusSessionIntegrity |
session.read |
— |
| GET | /sessions/divergence |
— | session.read |
— |
| GET | /sessions/graph |
Get-NerthusSessionGraph |
session.read |
— |
| POST | /sessions/graph |
Set-NerthusSessionGraph |
session.write |
— |
| POST | /sessions/batch |
— | session.read |
— |
| POST | /sessions/compare |
Compare-NerthusSessionParticipation |
session.read |
— |
| POST | /sessions/hash |
Set-NerthusSessionHash |
session.write |
✓ |
| GET | /sessions/narrator/{nick}/profile |
Get-NerthusNarratorSessionProfile |
session.read |
— |
| GET | /sessions/open |
Get-NerthusOpenSession |
session.read |
— |
| GET | /sessions/staged |
Get-NerthusStagedSession |
session.read |
— |
| GET | /sessions/{header} |
Get-NerthusSession |
session.read |
— |
| GET | /sessions/{header}/logs |
Get-NerthusSessionLog |
session.read |
— |
| GET | /sessions/{header}/bindings |
— | session.read |
— |
| POST | /sessions/{header}/bindings |
— | sample.confirm |
✓ |
| GET | /sessions/{header}/close-impact |
Get-NerthusSessionCloseImpact |
session.read |
— |
| POST | /sessions/{header}/rebaseline |
— | session.write |
✓ |
| POST | /sessions |
Add-NerthusSession (New-NerthusSession synonym) |
session.write |
✓ |
| POST | /sessions/report |
New-NerthusSessionReport |
session.report |
✓ |
| PATCH | /sessions/{header} |
Set-NerthusSession |
session.write |
✓ |
| POST | /workflows/open-session |
Open-NerthusSession |
session.write |
✓ |
| POST | /workflows/close-session |
Close-NerthusSession |
session.close |
✓ |
| POST | /workflows/close-session-by-discord |
— | session.close |
✓ |
| POST | /workflows/report-session-by-discord |
— | session.report |
✓ |
| POST | /workflows/query-by-discord |
— | entity.read |
— |
| POST | /workflows/finalize-close |
— | session.close |
✓ |
| POST | /workflows/close-reminder |
Send-NerthusCloseReminder |
discord.send |
— |
| POST | /workflows/distribute-session |
Invoke-NerthusSessionDistribution |
session.distribute |
✓ |
| POST | /workflows/apply-transfers |
Invoke-NerthusSessionTransfers |
currency.write |
✓ |
| POST | /session-registry/regenerate |
Invoke-NerthusSessionRegistryRegenerate |
session-registry.regenerate |
✓ |
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 /workflows/close-sessionneedssession.close, gated apart fromsession.write(author/edit) because closing is an enactment: it distributes the session, applies its@Transferdirectives (currency), and delivers its@Intelover Discord. The Narrator's authority over the coin is the session they ran; the narrator and radny bundles carrysession.close, so who could close before still can (Sessions).POST /workflows/finalize-closeis the second leg of a deferred close, and the CI close driver is its caller: when the first leg opens asesja/<stamp>merge request instead of writing tomain, the ledger entry it withheld is finalized here once that request has merged. The body is either the deferred descriptor the first leg returned —{ "pending": { "Header": "…", "Actor": "…", "TransferLedgerEntry": { … } } }, which carries the withheld transfer-ledger entry — or the bare{ "header": "…" }fallback, which finalizes the irreversible legs without that entry. Echo the descriptor back exactly as the first leg emitted it, member casing included: the request body is parsed case-sensitively, and the bare form cannot know what the first leg held back. Samesession.closecapability, same write gate.Add-NerthusSessionrequires-File(a repo-relative target); the daemon computes only the chronological position within it.POST /sessions/reportis the self-service path: an authenticated Gracz files a session as a draft underzgłoszenia/— the daemon picks the path (no-File), and the block is never distributed, closed, or awarding PU. It is how self-service session reports reach the lore repository as a reviewable merge request.@Transfer/@Intelare not accepted.POST /sessions/graphonly rebuilds the in-memory graph — nothing touches disk, so it carries no Write flag.POST /session-registry/regeneraterebuilds the derivedLokacje/registry. Adoption already materializes the tree and every model rebuild refreshes it, so the route is a repair, not the way the tree comes into existence. Likeworkflow.settle, its capability sits in no role bundle — a repo-wide write resolves only throughadmin.all.POST /sessions/{header}/rebaselinere-baselines one session's content hash. It exists so a narrator backfilling prose into a session they already closed stops it reporting as drifted, without the wholesalePOST /sessions/hashforgiving every other drift in the repository at the same time.- When staged sessions are enabled (
sessions.staged), the write routes change target:POST /sessionsandPOST /workflows/open-sessionaccumulate a draft instead of authoring into the lore (so-Filestops being required),PATCH /sessions/{header}edits that draft, and reads (GET /sessions/{header}, close-impact) see the owner's drafts through an overlay.GET /sessions/stagedlists them. With the flag off — the default — every one of these behaves exactly as documented below. See staged sessions.
List sessions¶
GET /v1/api/sessions returns the { count, items } envelope, each item the session
record. Filter with ?narrator=, ?from=, ?to=, ?entity=. The fixture holds eleven
sessions; the narrator filter narrows to the one Anward ran.
Page with ?limit= and ?offset=. Naming either adds total, limit and offset beside
the envelope, where total is what the window was cut from and count stays the number of
items actually returned. Naming neither answers whole, exactly as it always did — a consumer
written before paging existed reads the same body.
?fields= narrows each row instead of the row set; it is documented with the summary
listing below.
Response 200 (trimmed):
{ "count": 1, "items": [
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Date": "2026-07-01",
"Title": "Eraster rozmawia z Tussalem", "Narrator": "Anward", "Narrators": ["Anward"],
"Reporter": null, "ReporterSource": null, "SavedDate": null, "Generation": "Gen3" }
] }
Who ran the evening, and who saved the transcript¶
Every session row carries both, and they are different facts. The session model owns what they mean; this is their wire shape.
| Field | Type | What it holds |
|---|---|---|
Narrator |
string or null |
the narrator field as the corpus wrote it |
Narrators |
array of string | that field read as people, empty where the header named none |
Reporter |
string or null |
the character who saved the transcript |
ReporterSource |
string or null |
how the reporter was found — log-saver-slug today |
SavedDate |
YYYY-MM-DD or null |
when the transcript was written to disk |
Read Narrators, not Narrator. An evening may be adjudicated by several people, written as
one field — Shalley + MD — and Narrators is that field split into the two of them.
Narrator stays the string the header holds, so a row still displays as authored; a consumer
asking who ran this wants the array. Every route that filters or folds by narrator matches on
any member, so ?narrator=Shalley and ?narrator=MD both return a session they ran together.
Reporter is not a narrator by another name. A council-adjudicated declaration is headed Rada,
and the saved transcript's address is the only per-person attribution such an evening has;
ReporterSource names where the answer came from so a reader can weigh it. SavedDate is when
the transcript reached disk, against Date, which is the declared, in-fiction day. Neither
corrects the other — they are two calendars, and 41 evenings on the campaign corpus disagree.
What changed, and what did not
A session with one narrator serialises identically on every field that existed before —
Narrator included. The five fields above are additions beside it. Measured across the
campaign corpus at the change: 3 237 of 3 282 records byte-identical on their pre-existing
fields, and of the 45 that moved, 25 are headers whose narrator field was empty, was a
fragment of the title, or carried stray markdown, and 20 are LogStates probes.
List sessions without the prose¶
GET /v1/api/sessions/summary is the same listing with two fields left out: Description,
the narrator's account of the evening, and LogStates, which reports whether each transcript
sits in this host's archive. Same filters, same paging, same order.
Two thirds smaller on the campaign corpus — 6.4 MB against 2.2 MB — because the descriptions
are most of what /sessions weighs and a list view shows none of it. LogCount and LogsUrl
stay, so a row can still say a session has transcripts; ask
GET /sessions/{header} for the states of the one being opened.
Dropping LogStates is also what makes the route replica-safe: those states are a
filesystem probe, true of the host that answered, while everything left is a function of the
committed tree. Any host holding the commit gives the same answer, which is why the flag shows
up on GET /routes and why the edge may answer it away from the apex.
Response 200 (trimmed):
{ "count": 1, "items": [
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Date": "2026-07-01",
"Title": "Eraster rozmawia z Tussalem", "Narrator": "Anward", "Narrators": ["Anward"],
"Reporter": null, "SavedDate": null, "LogCount": 0 }
] }
Narrow a listing to the fields you draw¶
?fields= takes a comma-separated list of field names and answers with those fields only.
Both listings read it, GET /v1/api/sessions and GET /v1/api/sessions/summary, and it
applies after the page window: it changes what a row weighs rather than which rows come back.
Names match case-insensitively. Header survives whether it is named or not, because every
row here is addressed by its header and a page of rows nobody can open is not a smaller
answer. An unknown name is dropped rather than refused — a narrowing query is a hint about
payload size — so naming nothing recognizable answers headers alone, and naming every field
is the same answer as no ?fields= at all, only slower.
Against the campaign corpus the five fields a session table draws are 470 kB, and 100 kB gzipped. That gap is why the narrowing is a parameter rather than one more listing route.
The example asks for Date and LogCount.
Response 200:
{ "count": 1, "items": [
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Date": "2026-07-01", "LogCount": 0 }
] }
List sessions that need closing¶
GET /v1/api/sessions/open returns the sessions still to close — in the model, neither
recorded-closed (state/close-ledger.json / the committed nerthus.closed.md echo) nor
already distributed. Filter with ?narrator=. Every fixture session starts open; the
filter narrows to Anward's.
?narrator= folds to the person, not to the string. Narrators sign a session block with their
active character name or with their player name, and both are ordinary. The filter resolves each
side through the name engine — a Postać walked through @należy_do to its owning Gracz — and
compares the players, so asking for either spelling returns the same set. Against the dev corpus,
?narrator=MD and ?narrator=Materios Dragonius each answer 217 open sessions; before folding
they answered 88 and 123, and neither number was the truth about anybody. A name the index cannot
place still matches literally, so a Rada session signed with something unresolvable filters exactly
as it did before rather than disappearing.
The filter reads Narrators, so an evening two people adjudicated is owed by each of them:
?narrator=Shalley and ?narrator=MD both return the sessions headed Shalley + MD.
forms comes back beside count whenever a filter ran, and lists the distinct signatures that
folded into the answer — the member that matched, rather than the whole field. It is there to be
checked: a fold is a claim that two strings are one person, and this is where a reader sees the
claim instead of trusting a total.
One limit, stated because the count depends on it. A header joins its narrators with +, and
that is what the parser reads as two people. A header that writes the Polish conjunction instead —
Rothe i Materios Dragonius — stays one string, folds to whichever narrator the engine reaches,
and is counted for that one only. Three such sessions exist in 1314 open ones, and i is not
split because it is also an ordinary word: the same rule applied to zielarstwa i przywrócenia
would invent two narrators out of a fragment of a title.
Response 200:
{ "count": 1, "items": [
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Date": "2026-07-01",
"Title": "Eraster rozmawia z Tussalem", "Narrator": "Anward", "Narrators": ["Anward"] }
], "narrator": "Anward", "forms": ["Anward"] }
narrator echoes what was asked for and forms is what it folded to — one signature here, since
the fixture's narrator signs one way. Neither field appears on an unfiltered read: forms would
then be every signature in the corpus, which answers a question nobody asked.
Regenerate the Session Registry (preview)¶
POST /v1/api/session-registry/regenerate rebuilds the derived
Lokacje/ registry — one directory per known
place, one generated Sesje.md in each — and prunes what no longer maps to a location.
Adoption (POST /import) materializes the tree and every model rebuild refreshes it, so
this route is the repair seam: it answers with the same accounting on a tree that is
already current, and rewrites whatever drifted. ?dryRun=true returns the full plan
without touching disk: the root count, the file writes split into populated and empty,
the delete-set, foreign-file holdouts, and the placement accounting, which always
reconciles — placed + registered-unplaced + no-location = total. Against the fixture,
whose tree the import already wrote: five roots, eleven files (five of them empty
places) all unchanged, eight sessions placed, one registered under Nieprzypisane/, one
counted as carrying no location line.
Response 200 (trimmed):
{ "Applied": false, "Roots": 5,
"Directories": { "Intended": 11, "Created": 0, "Removed": 0, "KeptForeign": { "count": 0, "items": [] } },
"Files": { "Written": 0, "Populated": 7, "Empty": 4, "Unchanged": 11,
"Pruned": { "count": 0, "items": [] }, "Foreign": { "count": 0, "items": [] } },
"Sessions": { "Total": 11, "Placed": 9, "UnplacedRegistered": 1, "NoLocation": 1,
"BySource": { "Explicit": 4, "SelfRoot": 2, "Outerior": 1, "OuteriorClamped": 1 },
"ByReason": { "UnknownRoot": 0, "UnknownLocation": 1, "AmbiguousLocation": 0, "NoOuterior": 0 } } }
Staged sessions¶
A narrator writes a session over several sittings, and the four-to-twenty files it will
eventually reach are worked out only when it closes — so with sessions.staged enabled a
session accumulates as one draft file under gitignored .nerthus/staging/, invisible
to the model, the participation graph, integrity and the fan-out, until the close turns it
into lore. GET /v1/api/sessions/staged lists what is drafted: this host's own drafts in
full (an item per draft), and a fleet tier — one identifier per staging/* snapshot on
origin, headers only, because a draft's content stays private to its owner until it lands.
enabled reports whether staged mode is on. The default is off, and then the list is empty
and the write routes behave exactly as the rest of this page documents.
Response 200 with the flag off:
With staged mode on, a POST /v1/api/sessions drafts the session — note the absent
file — and the same route lists it back:
{ "enabled": true, "count": 1,
"items": [
{ "Sid": "2026-07-01-Eraster-w-Thuzal-Anward-8317a5aa",
"Header": "2026-07-01, Eraster w Thuzal, Anward", "Narrator": "Anward",
"Ref": "refs/heads/staging/2026-07-01-Eraster-w-Thuzal-Anward-8317a5aa",
"LastSnapshotUtc": "2026-07-01T18:04:11Z" }
],
"fleet": [
{ "sid": "2026-07-01-Eraster-w-Thuzal-Anward-8317a5aa",
"ref": "refs/heads/staging/2026-07-01-Eraster-w-Thuzal-Anward-8317a5aa", "local": true }
] }
Preview what closing enacts¶
GET /v1/api/sessions/{header}/close-impact dry-runs the close: the files the block is
distributed to, the coin its @Transfer moves, the players its @Intel reaches, plus a
description — a ready-to-send Polish summary (the close reminder uses the same text).
Response 200 (trimmed):
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Narrator": "Anward",
"Distribution": { "Targets": ["Postaci/Gracze/Eraster.md", "Postaci/Gracze/Lord Tussal.md"], "Skipped": [] },
"Transfers": { "Moves": [] }, "Intel": { "Sends": [] },
"description": "Sesja do zamknięcia: 2026-07-01, Eraster rozmawia z Tussalem, Anward - Eraster rozmawia z Tussalem\nPliki: …" }
Preview the close reminders¶
POST /v1/api/workflows/close-reminder nudges each open session to its narrator's own Discord
channel and the shared Rada channel; a session whose narrator does not resolve to a player goes
to the Rada channel only. The daemon reads both channels from committed lore — the narrator's own
@discord in nerthus.contributors.md (falling back to their Gracz @discord in
nerthus.entities.md, then the shared narratorzy role's @discord) and the rada role's
@discord in nerthus.contributors.md (the contributor store) — so
the request carries no channel map. The body accepts an optional { noDedup } (fire even inside
the cooldown window), and ?dryRun=true previews the sends without delivering. All eleven open
fixture sessions are Rada's, for two different reasons: Anward and Vanda Vanissa resolve to no
player at all, while Stefan resolves to one whose roster block carries no @discord, so there is
no channel of his own to route to. A narrator who resolves and has a channel takes a second
send beside Rada's — that path is exercised in tests/Nerthus.Smoke.Tests.ps1, whose fixture
gives Stefan a channel, and not here.
Response 200:
{ "Applied": false, "Open": 1, "Sends": [
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Channel": "rada", "Result": "WHATIF" }
] }
Close on behalf of a Discord invoker¶
POST /v1/api/workflows/close-session-by-discord is the /zamknij-sesję delegate: it resolves
the invoker's Discord id to a player and enforces ownership there (the owning narrator, or a
Radny for a Rada-owned session), not on the calling token — so the Worker/CI machine token cannot
close on anyone's behalf without a real linked identity. Body { discordId, hint? }; the hint is
an exact header, a date, or a title fragment (omitted → the caller's single open session). An
unlinked id is refused with a Polish message the followup shows the user.
Response 403:
{ "error": "discord_not_linked", "detail": "Błąd: Konto Discord niepołączone z graczem - poproś Radę o połączenie" }
Report on behalf of a Discord invoker¶
POST /v1/api/workflows/report-session-by-discord is the /zgłoś delegate, and the daemon-side
mirror of ci/Invoke-NerthusReportCi.ps1. Body
{ discordId, mode: "raw"|"form", source?, payloadB64 }, where payloadB64 is the base64 JSON
the ingest Worker builds ({ raw } or { fields }). It runs the same normalizer the CI driver
runs — strict for raw, lenient for form — and on acceptance authors the zgłoszenia/ draft
and pushes it as a zgloszenia/<stamp> merge request for the Rada, reusing the landing
transaction's branch primitives.
| Status | When |
|---|---|
201 |
published: { "Published": true, "Branch", "Header", "File", "Reporter", "Mode", "Warnings" } |
422 ReportRejected |
the strict boundary refused it: flags back to the invoker, no git touched at all |
503 report_publish_unavailable |
the pre-flight refused: a dirty worktree, a stranded publish branch, a converge-only replica, or an unreachable origin. The publish ends in reset --hard origin/main, so an unpublished local change vetoes the whole path — and the ingest Worker falls back to the pipeline, which costs one CI minute |
503 report_publish_failed |
the commit or the branch push failed; the serving branch is already back at origin |
403 discord_not_linked |
as above |
Read-only Discord queries¶
POST /v1/api/workflows/query-by-discord serves /status, /sesje-otwarte, and /pu. Body
{ discordId, intent: "status"|"open"|"pu" }; the answer is the Polish prose the followup shows.
It carries no write flag and writes nothing, which is why the dispatch accepts it on a
write-gated daemon. open shows the invoker's own sessions, or the whole backlog for a Radny.
There is no CI counterpart — a pipeline per question does not fit the budget — so a dark fleet
answers that it is unavailable rather than falling back.
Response 403:
{ "error": "discord_not_linked", "detail": "Błąd: Konto Discord niepołączone z graczem - poproś Radę o połączenie" }
Fetch one session¶
GET /v1/api/sessions/{header} resolves the header and returns the full projection —
locations, PU, transfers, logs, generation, and Description.
Description is the narrator's own account of the session: every line of the block the
metadata grammar did not claim, paragraphs intact. It is defined by elimination rather than by a
tag of its own, which is the only definition that cannot drift from the grammar — it is the
grammar, read the other way. The tags say who was there and where; this says what happened, and it
is the reason a transcript is worth opening.
A label this grammar does not recognise — - Informacje od Erastera: and its children, which no
tag claims — stays in the account rather than vanishing. Showing lore the parser did not
understand is honest; dropping it because it looked like metadata is not. A block that is only
tags answers with an empty string.
Response 200 (trimmed):
{ "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Date": "2026-07-01",
"Title": "Eraster rozmawia z Tussalem", "Narrator": "Anward", "Narrators": ["Anward"],
"Reporter": null, "ReporterSource": null, "SavedDate": null, "Generation": "Gen3",
"Description": "Eraster odwiedził rezydencję Tussala…",
"Locations": ["Thuzal", "Thuzal/Rezydencja Tussal"],
"PU": { "Eraster": "0.2", "Lord Tussal": "0.2" } }
Sessions by narrator¶
GET /v1/api/sessions/narrator/{nick}/profile lists every session a narrator ran.
Response 200:
{ "narrator": "Anward", "count": 1, "sessions": [
{ "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "date": "2026-07-01" }
] }
Integrity check¶
GET /v1/api/sessions/integrity compares each session against its stored content hash and
reports tiered findings ({ count, total, items }). Before any hash is baselined every
fixture session shows as Unhashed.
Response 200:
{ "count": 11, "total": 11, "items": [
{ "severity": "Medium", "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "issue": "Unhashed" },
{ "severity": "Medium", "header": "2026-01-15, Ogarnianie noży, zwiadu ptaków i iluzji mięsa", "issue": "NarratorDropped", "field": "zwiadu ptaków i iluzji mięsa" },
{ "severity": "Medium", "header": "2025-11-12, Zajączek kucharka pichci dla lisiczek", "issue": "NarratorAbsent" },
{ "severity": "Medium", "header": "2024-02-18, Obrady w Ithan, Anward", "issue": "LogsUnparsed", "value": "[Obrady w Ithan](/Logi/2024-02/Obrady w Ithan.log)" }
] }
Copies that disagree¶
A session is one header written into several files, and GET /v1/api/sessions/integrity above
cannot see them disagree: it compares the copy that won the dedup against the stored hash, and
never compares two copies with each other. GET /v1/api/sessions/divergence is that comparison.
Two counts travel in every answer, whichever one is being rendered. divergentTotal is every
header whose copies differ in bytes at all; beyondWhitespaceTotal is the subset that still
differs once whitespace is stripped, and whitespaceOnly is the difference between them. On the
campaign corpus that is 372, 332 and 40. The default renders the 332, because a reflow is
not something an operator can act on — but the 372 is published beside it, so that two people
holding the two figures can see they are the same measurement rather than a contradiction.
?beyondWhitespace=false renders all 372 instead. Any other value is 400, deliberately: the two
answers differ by 40 headers and both look right, so a silent fallback would hand somebody who
asked for 372 the number 332 with nothing in the body saying which question was answered.
items is not capped, and count always equals its length.
Each item carries differs (pu · prose · whitespace) and a severity: Critical when the
copies disagree about @PU, High for any other content difference, Medium for whitespace.
puDiffers is true, false, or null when a copy could not be read back — a header whose
file moved between the model walk and the request is never reported clean, and puUnresolved
counts those.
puParseUnstable counts something else and must not be added to puDisagreements: copies whose
whitespace-stripped bodies are identical — the same lore — but whose @PU blocks parse
differently. That is a defect in the parser rather than a disagreement between authors, and
reconciling those copies would change nothing.
The participation graph¶
GET /v1/api/sessions/graph returns the session⇄entity edges, tiered by how the entity was
found (file placement vs @PU/@Uczestnicy/@Transfer).
Response 200 (trimmed):
{ "Sessions": 11, "Entities": 2, "EdgeCount": 2, "Edges": [
{ "Session": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "Entity": "Eraster", "Tier": 1 }
] }
Rebuild the graph¶
POST /v1/api/sessions/graph refreshes the in-memory model and returns the recomputed
counts. It mutates no lore — a model refresh only — needs session.write.
Response 200:
Many session records in one call¶
POST /v1/api/sessions/batch with { headers: [...] } returns one record per header, and exists
for the fan-out a scenes view produces: every scene in /entities/{name}/scenes names its sessions,
so a caller rendering a page already holds the whole set and would otherwise ask for each one
separately. It is POST for the reason /sessions/compare is — a session header carries commas and
spaces, and there may be hundreds of them, which is past what a URL should hold. Same capability,
session.read, so it is not a new posture.
The body is { Count, Requested, Missing, Records }. Headers are deduplicated on the key the store
uses, so a page naming one session from two scenes costs one lookup. A header that resolves to
nothing is reported in Missing rather than dropped — a shorter set coming back is otherwise
indistinguishable from a session the caller should stop asking for.
More than 1000 headers is refused with 422 TooManyHeaders rather than truncated. The cap is
the scenes page maximum, so the request this route exists to serve always fits; silently dropping
members of a set the caller enumerated would render a page with holes and no signal.
Compare participation¶
POST /v1/api/sessions/compare with { entities: [...] } returns each entity's session set
and their intersection. Both characters share the one fixture session.
Response 200 (trimmed):
{ "Entities": [
{ "Entity": "Eraster", "Count": 1, "Sessions": ["2026-07-01, Eraster rozmawia z Tussalem, Anward"] },
{ "Entity": "Lord Tussal", "Count": 1, "Sessions": ["2026-07-01, Eraster rozmawia z Tussalem, Anward"] }
], "Shared": ["2026-07-01, Eraster rozmawia z Tussalem, Anward"], "SharedCount": 1 }
Kto to? — a session's bindings¶
GET /v1/api/sessions/{header}/bindings is the narrator's question answered per session: every
form the transcripts write that the lore does not name, resolved as far as the evidence allows.
Four producers feed one shape — the alias sieves (forms with a block and a line to paste), the
shared-epithet tier (a common noun several blocks answer to, bound per cast), the format-10
candidates Nerthus.Lang marks outside any span (Elfka, Kapitan), and the sample store's
proposed questions and rulings for the session.
status |
Means | Who decided |
|---|---|---|
answered |
a confirmed entity-span row with fields.referent for this session |
a person |
bound |
the engine isolated one cast member; guess.eval says by what evidence |
the engine |
open |
several rivals or none — rivals lists the whole cast, the ones with evidence first |
nobody yet |
known forms — a heading, an @alias, an @forma_sesyjna claim, or a name the resolver reaches —
are counted in counts.known and never listed. guess.eval on a bound row is one of sieve (an
alias row at exact/wysoka with no rival), engine (exactly one cast member cleared the
answered-by gate for the form in this session — the same gate the epithet harvest uses over the
archive, run on one evening), provisional (a cast of one), propagated (a person bound the form
in another session whose cast holds the same block, and nobody else answers here; guess.from
names that session). Two facts the answered-by count cannot see are read off the transcript before
the gate (2026-09-05, after the 125 audit found three of four wrong engine rows had one shape —
a narration line names the form and the other player replies): the past-tense gender of the lines
about the form («Paladynka podziękowała») against the -łem/-łam of each cast member's own signed
lines — a member whose gender contradicts the lines leaves the answers and stays on the row as a
rival whose evidence begins wykluczony — and a role noun in a cast of one is never bound,
because the one player answers every line and the line is about the NPC the narrator runs;
name-shaped candidates in a cast of one keep provisional. A sixth eval, shared (2026-09-05,
ruling H125.5): the epithet harvest fits two or more cast members and the answered-by gate
isolates none — the row is bound with guess.entities listing every fitting member, evidence
first, guess.entity the first of them and margin: null; a person's ruling propagated from
another session lists rather than binds here and stands first, guess.from naming that session.
The panel draws every name, hovers the list on the transcript mark and offers no t — the digit
picks. Where the answers do isolate one, engine wins as before. The list is the harvest's plus
the lore's: a cast member whose block carries - @rasa: naming the race the form names (elf for
«Elfka»/«Elf», kitsune for «Lisica», wampir for «Wampirzyca», krasnolud, drow) joins it
with the tag as evidence (tag schema), so a block the harvest never learned is
still in the room. A bound row is served, never deposited: a confirmed row means a person was
shown it. store: false says the sample store is not readable on this host, so answered and
propagated cannot appear. A session whose logs are all NotFetched — no archive entry, no
sidecar — answers its cast with zero rows and every count at zero rather than an error; 838 of the
2304 sessions with logs on the development corpus were in that state on 2026-09-05, every one
before 2024 among them.
Units — how many, of what¶
A form can be a what rather than a who, and the panel says so. «Straż floty w liczbie
pięćdziesięciu» is a body of fifty, «Trzech gońców» three messengers, «Dwa tuziny obsługi»
twenty-four; none of them is a cast member, and binding one to the player who replied next is the
same defect one level up. A row is a unit when its head is a group lemma, when its count has an
upper bound above one, or when the count is unbounded («garstka», «tłum»). It carries
kind: "unit", unit: true, and a relation; it sits in the SAME list as the person rows
(ruling H126.1) with the jednostka tag on the panel.
A unit leaves the person machinery entirely: the answered-by gate, provisional, shared and the
@rasa tier do not run on it, and the cast is never listed as its rivals — a race scope says
the guard is elvish, not that the elf in the room is the guard. The precision floor above does not
apply either: a unit is a common noun, written once and lower-case, and its floor is that a scope
or a count is present at all.
The row key is lemma|scope.lemma, so «straż floty», «straż pożarna» and bare «straż» are three
rows and a ruling on one never stamps the others. A singular scoped role — «Kwatermistrza»,
«kapitan fortu» — stays a person row with every rule it had and only gains the relation.
relation is null on every row with neither a scope nor a count, and otherwise carries:
| field | means |
|---|---|
source |
which instrument read the row: sidecar (format 11's lifecycle stream) or daemon (the text grammar). Not decoration — a resolved scope and a guessed one are different claims |
modality |
the burst's reading, sidecar rows only: value (the cue that fired) and frame (the frame it sits in) — H127.2's two fields. Null on a daemon row, because one line is not a burst |
scope |
what the head is a unit OF: surface, lemma, kind (unit, organisation, place, race, proper, function), entity when it resolves to exactly one indexed block, candidates when it reaches several |
quantity |
value, low, high, exact, approx, each, form (digits, word, collective, idiom, range, vague, many), the surface as the line writes it, the rule that read it, and line/start/end |
members |
the names the line lists after «czyli», a colon or a dash, when their count equals the quantity — { surface, entity } each |
parent |
the key of the unit this scoped singular belongs to, when the session writes one |
link |
tier A of the linker (127 step 5): the unit's own FORM against the name index, held to a Grupa. state is linked with entity and type: "Grupa" when every word of the form is a word of exactly one Grupa heading, as written or declined, and the heading has no word the form lacks; otherwise unresolved with rule saying why — bare-head (a head with no scope is never linked: «straż» alone would bind every guard in the corpus to one block), form:none, form:held:<type> (the form names a block of another type, «Gildia Kupców» the hall, listed in candidates with its type), form:partial (a Grupa heading the form only partly names), form:ambiguous (two Grupa headings fit). candidates is { entity, type } each. Accuracy and coverage are published together by 126-ner-next/scripts/85-unit-link-coverage.py and never by this field |
Three more guess.eval values, all unit-only: grupa — relation.link is linked, which is
the body's own block and outranks its scope («Straż Miejska» names the Grupa; the scope «miejska»
would only have said which town); scope — the scope resolves to exactly one
indexed block («strażą Thuzal» → Thuzal), which is the body's own identity and not a guess about a
person; and enumerated — the line counts its members out («elfowie w liczbie pięciu, czyli
Khidell, Fenrod, Nyelagund, Tinleid i Falmir»), bound only when every member resolves and open
with the members listed otherwise. A unit's referent space is a Grupa heading, the scope's
entity, undeclared (a real body the lore has no block for) or none.
Where a line's sidecar carries a format-11 lifecycle event, the read takes it and does not run
the text grammar over that line at all. The sidecar is the instrument: it knows the scope
resolved through the run — «floty» on RNdb9gG3:101 is the caravan defined on line 99 — and the
members the parse bounded, neither of which a scan of one line can have. Running both would put two
readings of one line in front of a narrator.
For every other line the daemon's own grammar reads it (daemon/data/quantities.json, the bounds
ruled in H126.4), with the head lexicon taken from the sidecars' own lemma/kind pairs plus a
supplement for the people and unit nouns role-lexicon.json lacks. One session can hold both — a
format-10 sidecar for one log and a format-11 sidecar for another — and it produces ONE list, with
relation.source saying per row which instrument read it. A disagreement between the two on an
archived line is a listed defect rather than a tie-break.
Precision before recall: a candidate is listed when it is written capitalised at least twice in
the session, is not an honorific (Pan, Pani, Sir…), and is not capitalised only where lines
begin unless it recurs three times. A proposed question the store holds for the session is listed
whatever the gate thinks, with its ids in sampleIds so a ruling goes to
POST /training/samples/confirm with a referent.
{ "status": 200, "exists": ["header", "cast", "counts", "items", "items.0.unit", "items.0.relation"], "matchRegex": { "items.0.status": "^(open|bound|answered)$" } }
{
"header": "2026-08-01, Elfka w Thuzal, Thuzal",
"cast": ["Eraster", "Vinkor"],
"counts": { "known": 1, "denied": 0, "bound": 1, "open": 1, "answered": 0 },
"confirmed": 0,
"store": true,
"count": 2,
"items": [
{ "form": "Kapitan", "lemma": "kapitan", "kind": "role", "source": "wzmianka", "occurrences": 2,
"lines": [ { "log": "…", "index": 5, "text": "Wszedł Kapitan." } ],
"status": "open", "guess": null,
"rivals": [ { "name": "Eraster", "answered": 1, "evidence": "odpowiada raz na linię z tą formą" },
{ "name": "Vinkor", "answered": 1, "evidence": "odpowiada raz na linię z tą formą" } ],
"referent": null, "sampleIds": [], "known": null, "confidence": null, "line": null },
{ "form": "Elfka", "lemma": "elfka", "kind": "role", "unit": false, "source": "wzmianka", "occurrences": 3,
"lines": [ { "log": "…", "index": 1, "text": "Elfka spojrzała na Vinkora." } ],
"status": "bound",
"guess": { "entity": "Vinkor", "eval": "engine", "margin": 1, "evidence": "odpowiada 2 razy na linię z tą formą, rywal 1", "from": null },
"rivals": [ { "name": "Vinkor", "answered": 2, "evidence": "…" }, { "name": "Eraster", "answered": 1, "evidence": "…" } ],
"relation": null,
"referent": null, "sampleIds": [], "known": null, "confidence": null, "line": null },
{ "form": "Straż floty", "lemma": "straz|flota", "kind": "unit", "unit": true, "source": "wzmianka", "occurrences": 1,
"lines": [ { "log": "…", "index": 101, "text": "Straż floty w liczbie pięćdziesięciu. Trzech gońców." } ],
"status": "open", "guess": null, "rivals": [],
"relation": {
"source": "sidecar",
"modality": { "value": "planned", "frame": "hypothetical" },
"head": "Straż",
"scope": { "surface": "floty", "lemma": "flota", "kind": "unit", "entity": "Karawana", "candidates": [] },
"quantity": { "value": 50, "low": 50, "high": 50, "exact": true, "approx": false, "each": false,
"form": "idiom", "surface": "w liczbie pięćdziesięciu", "rule": "backward_idiom",
"line": 101, "start": 12, "end": 36 },
"members": [], "parent": null,
"link": { "state": "unresolved", "entity": null, "type": null, "candidates": [], "rule": "form:none" } },
"referent": null, "sampleIds": [], "known": null, "confidence": null, "line": null }
]
}
POST /v1/api/sessions/{header}/bindings is the person's answer for a form the store never
asked about: { "form": "Elfka", "lemma": "elfka", "referent": "Vinkor", "label": "person" }
writes one confirmed entity-span row per occurrence the read listed — the capitalised mentions
whose lemma matches; a lower-case mention the precision gate never showed is not stamped (before
2026-09-06 it was: «Elf» listed 3 times, 16 rows written) — with
fields.scope = the header, fields.proposed_by: kto-to and method.accepted_by from the
caller's token; lemma, scope and label (default person) are optional. scope is the unit row key's
second half, so «straż floty» is ruled without touching «straż»: the write then matches what the
quantity grammar reads off each line rather than the sidecar's mentions, the locator spans the
scoped form, the label defaults to group, and the row carries fields.unit — the count and what
the body is a unit OF (Nerthus.Lang.Annotations !12; the name is unit because fields.scope is
already a required field of the class meaning the header). It never takes a quantity: the engine
measured that and the row it writes carries it. "referent": "none" is the
person saying the form is not a name here (ruling H124.3): the same rows land with label none,
an empty referent and verdict denied, and the read hides the form for this session from then on
(counts.denied says how many). ?dryRun=true answers with
would and writes nothing. It is idempotent on an occurrence — the id is a hash of header, log
and offsets — so the same ruling twice reports unchanged, and the same occurrence ruled to
another referent is refused 409 SampleAlreadyConfirmed. Other refusals: 404 SessionNotFound,
503 SampleStoreUnavailable, 422 BadSampleLabel, 422 BadSampleReferent (not a name the
index holds, and not undeclared), 422 BindingFormUnseen. Both routes belong to project
124-kto-to; the dashboard's Kto to? panel is their consumer.
A session's logs¶
GET /v1/api/sessions/{header}/logs joins the session's @Logi URLs to their archived
transcripts. This one has none, so the list is empty — a plain read never touches the
network.
Each entry carries Url, Name, Status (Fetched, Failed, NotFetched), and, for an
archived one, Parsed beside FetchedAt and ContentHash. Name is the archive name
GET /logs/archive/{log} is addressed by, so a reader holding this list can fetch
one transcript on its own rather than the session's whole log set.
?include=mentions puts the annotation's mention candidates on each line, the way
the archive read does — and, like there, it answers from the sidecar rather than the
precomputed view. Parsed.Lines is local speech only, the same narrowing every transcript read applies
(Logs) — a private, group, or global line never leaves the daemon. LineCount
still counts the whole transcript and LocalLineCount counts what is left, so the two never
have to be inferred from each other. ?speaker= narrows further, to one voice.
Response 200:
Baseline the content hashes¶
POST /v1/api/sessions/hash re-baselines the content-hash sidecar from the current model
and returns how many sessions it stamped — the eleven fixture sessions here.
Response 200:
Re-baseline one session¶
POST /v1/api/sessions/{header}/rebaseline stamps the current content hash for a single
session and leaves every other recorded hash alone. Closing a session does not end the work
on it — prose gets backfilled afterwards — and that edit would otherwise surface as
ContentChanged on integrity forever. Re-baselining says "this is the
text I meant", for this session only.
The response carries previousHash so the change is auditable; on a session that was never
hashed it is empty. An unknown header is a 404.
Response 200:
{ "rebaselined": true, "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward",
"previousHash": "…", "hash": "…" }
Preview a create without writing¶
POST /v1/api/sessions?dryRun=true returns 200 { Applied: false, Preview } and writes
nothing — proven by the follow-up 404. The new session is a follow-up meeting in the
Gildia Teologów, scene-derived from Tussal's world.
POST /v1/api/sessions?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>
{ "file": "Świat gry/Thuzal/Sesje lokalne.md", "date": "2026-07-02",
"title": "Narada w Gildii Teologów", "narrator": "Anward",
"locations": ["Thuzal", "Thuzal/Gildia Teologów"], "pu": { "Eraster": "0,1" } }
await fetch("https://evocation.nerthus.pl/v1/api/sessions?dryRun=true", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({
file: "Świat gry/Thuzal/Sesje lokalne.md", date: "2026-07-02",
title: "Narada w Gildii Teologów", narrator: "Anward",
locations: ["Thuzal", "Thuzal/Gildia Teologów"], pu: { Eraster: "0,1" },
}),
});
Response 200 (trimmed):
{ "Applied": false, "Header": "2026-07-02, Narada w Gildii Teologów, Anward",
"File": "Świat gry/Thuzal/Sesje lokalne.md", "Preview": "### 2026-07-02, Narada w Gildii Teologów, Anward\n..." }
Create a session¶
POST /v1/api/sessions authors the block into the named @plik file and returns 201. The
follow-up GET proves it landed.
await fetch("https://evocation.nerthus.pl/v1/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({
file: "Świat gry/Thuzal/Sesje lokalne.md", date: "2026-07-02",
title: "Narada w Gildii Teologów", narrator: "Anward",
locations: ["Thuzal", "Thuzal/Gildia Teologów"], pu: { Eraster: "0,1" },
}),
});
Response 201:
{ "Applied": true, "Header": "2026-07-02, Narada w Gildii Teologów, Anward",
"File": "Świat gry/Thuzal/Sesje lokalne.md", "Action": "Appended" }
Edit a session¶
PATCH /v1/api/sessions/{header} splices only the managed sections you pass — here, adding a
@Logi entry to the session just created. Prose and unknown tags stay byte-for-byte.
const header = "2026-07-02, Narada w Gildii Teologów, Anward";
await fetch(`https://evocation.nerthus.pl/v1/api/sessions/${encodeURIComponent(header)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({ logs: ["https://www.margonem.pl/dziennik/2026-07-02-narada"] }),
}).then((r) => r.json());
Response 200:
{ "Applied": true, "Header": "2026-07-02, Narada w Gildii Teologów, Anward",
"File": "Świat gry/Thuzal/Sesje lokalne.md" }
Open a session (draft)¶
POST /v1/api/workflows/open-session begins an in-progress session — a header + prose draft.
Previewed with ?dryRun=true; the returned Preview writes nothing. The scene is a supper
at Tussal's, scene-derived from the prior session.
await fetch("https://evocation.nerthus.pl/v1/api/workflows/open-session?dryRun=true", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({
file: "Świat gry/Thuzal/Sesje lokalne.md", date: "2026-07-03",
title: "Wieczerza u Tussala", narrator: "Anward",
}),
});
Response 200 (trimmed):
{ "Applied": false, "Header": "2026-07-03, Wieczerza u Tussala, Anward",
"File": "Świat gry/Thuzal/Sesje lokalne.md", "Preview": "### 2026-07-03, Wieczerza u Tussala, Anward" }
Close a session (preview)¶
POST /v1/api/workflows/close-session enacts a session — distribute, transfers, intel, then
re-hash. Previewed here with ?dryRun=true, which enacts nothing.
await fetch("https://evocation.nerthus.pl/v1/api/workflows/close-session?dryRun=true", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({ header: "2026-07-01, Eraster rozmawia z Tussalem, Anward" }),
}).then((r) => r.json());
Response 200:
The gate is session.close, not session.write — a token that may author and edit sessions still cannot enact a close:
Response 403:
Landing a staged close¶
With sessions.staged on, closing a drafted session is a single landing transaction rather than an in-place enactment. The daemon converges fresh main, runs a semantic gate over the draft, materializes it against the converged tree — the canonical block, the derived fan-out, and @Transfer recomputed from the session's directives against current balances rather than replayed from old amounts — commits once authored by the enactor, and pushes inside a compare-and-swap loop that re-converges and retries when another writer lands first. Every irreversible side effect — @Intel, the dedup ledgers, the committed echoes — runs only after that push succeeds, so committed state never trails what has already been enacted.
A close the gate refuses returns its findings in the response body with nothing written and the draft intact (422). A finding the gate marks as needing a human — an unresolved fan-out target, or a change on main an operator must reconcile — and a close that exhausts its retries do not force the write: the daemon opens a sesja/<stamp> merge request to the Council carrying the session and the findings, and main keeps no partial state. Merging that request runs the follow-up finalize. Direct human pushes to the lore repository are never blocked; the gate and the post-converge lint catch the drift they introduce. See Sessions.
Distribute a session (preview)¶
POST /v1/api/workflows/distribute-session fans the block out to every participant's file.
?dryRun=true returns the target list with applied: false and writes nothing — needs
session.distribute.
await fetch("https://evocation.nerthus.pl/v1/api/workflows/distribute-session?dryRun=true", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({ header: "2026-07-01, Eraster rozmawia z Tussalem, Anward" }),
}).then((r) => r.json());
Response 200 (trimmed):
{ "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward",
"distributedTo": ["Świat gry/Thuzal/Sesje lokalne.md"], "skipped": [], "applied": false }
Apply a session's transfers (preview)¶
POST /v1/api/workflows/apply-transfers folds the session's @Transfer directives into
holdings, dedup-guarded by header. This session has no transfers, so ?dryRun=true moves
nothing — needs currency.write.
await fetch("https://evocation.nerthus.pl/v1/api/workflows/apply-transfers?dryRun=true", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({ header: "2026-07-01, Eraster rozmawia z Tussalem, Anward" }),
}).then((r) => r.json());
Response 200:
{ "Applied": false, "Header": "2026-07-01, Eraster rozmawia z Tussalem, Anward",
"Moves": [], "Warnings": [], "AlreadyApplied": false }
Report a session (self-service)¶
POST /v1/api/sessions/report lets an authenticated Gracz (capability session.report,
seeded in the gracz bundle) file a session as a draft under zgłoszenia/ — rendered in
canonical @-tag form, but never distributed, closed, or awarding PU. The daemon chooses the
quarantine path (the player never picks a @plik), and @Transfer/@Intel are rejected.
zgłoszenia/ is an inbox, not a scanned lore directory: the draft does not become a live
session in the model — the follow-up GET by its header 404s. The Rada reviews the
resulting file — a merge request opened by CI, or a sync-published branch on a live daemon — and
a Narrator enacts it into canon with the normal workflow. Here a token minted with the gracz
role files a report.
await fetch("https://evocation.nerthus.pl/v1/api/sessions/report", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify({
date: "2026-07-05", title: "Patrol graczy", narrator: "Bez narratora",
locations: ["Thuzal"], prose: "Bez narratora — zwykły patrol miasta.",
}),
});
Response 201 (File is the daemon-chosen quarantine path; Reporter is the caller's audit actor):
{ "Draft": true, "Reporter": "gracz:100001", "Applied": true,
"Header": "2026-07-05, Patrol graczy, Bez narratora",
"File": "zgłoszenia/2026-07-05-Patrol-graczy-20260705-120000.md", "Action": "inserted" }
A token without session.report is refused at the capability check, before any write:
Response 403:
Report a session — raw block (strict) or form (lenient)¶
POST /v1/api/sessions/report also accepts two loose shapes the self-service front-ends use,
both normalized to canonical by the daemon (impl analysis: the report normalizer reuses the
session parser as its integrity authority):
{ "raw": "…" }— a whole```block (the message-command path). Strict: any recovery needed →422 ReportRejectedwithflags, no write. A properly formatted report passes untouched.{ "header", "body", "logs", "locations", "pu" }— the Discord modal's five fields. Lenient: deterministic fumbles (unpadded date, colon-less/multi-pair PU, flush-left lists) are recovered; theflagsride back and onto the merge request.
A canonical raw block passes strict and writes the draft:
Response 201:
{ "Draft": true, "Applied": true, "Header": "2026-07-08, Zwiad w Thuzal, Rada",
"File": "zgłoszenia/2026-07-08-Zwiad-w-Thuzal-….md", "mode": "raw", "flags": [] }
A raw block that needs recovery is rejected under strict, with the flags to fix:
Response 422:
{ "error": "ReportRejected", "mode": "raw", "strict": true, "flags": [
{ "severity": "warn", "code": "AddedHeaderPrefix", "message": "dodano prefiks ### do nagłówka" },
{ "severity": "warn", "code": "PaddedDate", "message": "uzupełniono datę: '2026-7-8' -> '2026-07-08'" },
{ "severity": "warn", "code": "RecoveredPuColon", "message": "uzupełniono ':' w PU: 'Eraster 0,1'" }
] }
The Discord-modal form is lenient — it recovers and writes, warnings attached:
Response 201: