The contributor store¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
nerthus.contributors.md is a committed, hand-authored source of truth at the
repository root that says who the campaign's people are, what each of them may do, and
where each is reached. Every human is one block in it — players, Narrators and Radni
alike — together with what each role grants and where it is notified. It shares the
entity index's Markdown grammar and the one C# scanner, but no route and no tick ever
writes it. This page is the contributor model of that store; the closed tag tables, the
label⇄bundle map, and the validation findings live in the
contributor store reference.
Concepts¶
- Osoby — the
## Osobysection, one### <Osoba>block per human. This model owns the block's authorization tags: any number of@rolalabels, optional personal@dostępcapabilities, any number of purposed@discordchannels, and any number of@klucz_apiAPI-key grants — all bound to a person by@margonemid, the identity key. The block's player-facing tags (@status,@tematy_zastrzeżone,@alias) are the roster model's and are not redefined here. A block carrying@rola: graczis what makes somebody a player: the daemon projects oneGraczrecord per such block into its entity model (Players & characters). - Narrator signal — since the August 2026 repository splits the store is also where
the repository states who its narrators are and what each is cleared to run:
@rangacarries the old narrator table'sRangacell verbatim, and@uprawnienieone permission ceiling per line. Both are deliberately redundant beside@rolaand@status, because/governance/narrator-signalsreconciles them against the Rada's governance ledger as an independent second opinion — re-derived from the other tags, the signal would agree with them by construction. The wiki page that used to carry this,Nerthus/Informacje/Narratorzy.md, left with the public website; the commits that carried it remain the signal's historical era. - Purpose — a
@discordnames the capacity it serves, drawn from the same role vocabulary, so one person is reached on their player channel as a player and their narrator channel as a narrator. - Role — the
## Rolesection, one### <etykieta>block per role. Each block carries the role's@dostępcapabilities and its shared@discordDiscord channel (achannel, categoryname pair the bot resolves and posts to by name). For instance, the read-onlyITrole carries no@dostęp, so it keeps its bakeditbundle and only names its shared channelkanal-wewnetrzny-it, where the fleet posts technical notices (Fleet). - Role label → bundle key — the store speaks the collective Polish labels
(
narratorzy,rada,namiestnik,koordynator,gracz), which map to the code's capability-bundle keys (narrator,radny,namiestnik,koordynator,gracz). The capability bundles themselves are the capabilities reference's. - Personal override — a person's
@dostępcapability replaces the role on its<resource>.<action>stem: personalsession.write.ownnarrows the role'ssession.writeto own-scope on the same stem. - API key (
@klucz_api) — a long-lived credential grant inside a person's block. The tag stores only thesha256:of anak_bearer, never the value; whoever presents the raw value authenticates as that Osoba funkcyjna on every fleet host. Granted by pasting a line, revoked by deleting it. - Capability — an existing
<resource>.<action>[.own]id (session.write,pu.award,currency.write,governance.write,admin.all) — the same closed set the capabilities reference owns.
The store is the committed statement of who a person is and what they may do. A session
token always carries only the gracz role, so a Narrator or Radny is elevated by this
file. See (Permissions).
The Markdown shape¶
The store uses the entity index's grammar — ## section / ### block /
- @klucz: wartość, read by Nerthus.MarkdownScanner. Its two sections:
## Osoby
### Stefan
- @margonemid: 100001
- @status: Aktywny
- @rola: gracz
- @rola: narratorzy
- @discord: gracz-stefan, gracze, gracz
- @discord: narrator-stefan, narratorzy, narratorzy
- @klucz_api: sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
- @tematy_zastrzeżone: Banany
### Roman
- @margonemid: 100002
- @status: Aktywny
- @rola: gracz
- @rola: rada
- @dostęp: session.write.own
## Role
### rada
- @dostęp: entity.read, session.write, pu.award, governance.write, discord.send
- @discord: powiadomienia-rady, rada
### narratorzy
- @discord: powiadomienia-narratorow, narratorzy
Stefan is the shape worth reading twice. He plays and narrates, and that is one
block with two @rola lines and two @discord lines. The third component of each
@discord is its purpose: PU and @Intel reach him on gracz-stefan because they
ask for gracz; the close reminder reaches him on narrator-stefan because it asks
for narratorzy.
@dostęp is multi-valued: it accepts a comma-packed list on one bullet and repeated
bullets alike. The exact tags, their kinds and arity, the @discord value grammar, the
label⇄bundle map, and the validation findings are the
contributor store reference's.
Several roles, one person¶
A person holds as many @rola lines as they have capacities, and their effective
capabilities are the union of those roles' bundles with their personal @dostęp
applied once, last, over that union — the resolution order is
Permissions's.
Two consequences worth stating:
- A person with no
@rolaat all is elevated by nothing. They are in the file and they are nobody in particular. - A
## Roleblock forgraczcarrying@dostępretunes every player, because every player holds@rola: gracz.
A role is retired by closing the range on its own line — @rola: narratorzy (2020:2024)
— never by a status flag. @status, the person's standing in the campaign, is a separate
axis owned by the roster model.
Who may write this file¶
No route and no tick ever writes nerthus.contributors.md. A person's block is
an editorial statement — who plays, in what capacity, reachable where — and the daemon
has no business inventing one on a request path, where the result would authenticate and
notify people before any contributor had seen it. Every roster write route answers a
refusal naming this file (API reference).
There is exactly one machine write, and it is bounded: at adoption, when the
## Osoby section is absent, the importer appends it once from the legacy
Gracze.md roster and never touches it again (Adoption).
What makes the file trustworthy, though, is not the absence of writers — it is
convergence. A host authenticates and notifies only from the store as converged from
the sync branch, the copy every contributor can see and review; a checkout on a side
branch or a detached HEAD refuses every nak_ bearer until it returns. That is the same
rule the @klucz_api grant already rides, and it is the honest safeguard: a grant is
minted by the daemon and pasted by a human.
How effective capabilities resolve¶
Resolve-NerthusEffectiveCapabilities folds a person's capabilities on every request,
in layer order:
- the session-token role bundles — always just
gracz; - the contributor store — the person's
@rolarole caps, then their personal@dostępoverriding the role per<resource>.<action>stem; - the moderators roster (
nerthus.moderators.md) — themc/smcbundle of every@rolawhose range covers now, unioned in; - per-Person runtime grants (union) then revokes (subtracted last, so a revoke wins).
flowchart LR
T["Session token<br/>(gracz role)"] --> U["∪ @rola role caps"]
U --> O["personal @dostęp<br/>(overrides per stem)"]
O --> M["∪ moderators-roster<br/>mc/smc bundles"]
M --> G["∪ runtime grants"]
G --> R["− runtime revokes<br/>(revoke wins)"]
R --> E["Effective capabilities"]
Layer 3 is a union because the two rosters have separate merge rights: the lore
repository grants what it is competent to grant, the moderators' repository likewise,
and a person in both files holds both. It sits before the runtime revokes so a revoke
still wins over an mc bundle — a capability the Rada took away must not return
because the person also moderates.
Because the fold runs per request, a hand edit to the store elevates or narrows a live
token immediately. A role's caps come from its ## Role block: when the block carries
@dostęp it overrides the baked bundle for that role entirely, everywhere the
daemon resolves that role (a functional person and a named token minted with
-Roles narrator alike); when the block omits @dostęp the baked bundle stays and
the block has only added a channel. The committed .nerthus/data-tables/roles.json
remains a lower-priority fallback seed; this store's ## Role section is the
authoritative committed role definition (Permissions).
How an API key grant works¶
An API key is the store's third kind of statement: not who a person is
(@margonemid) nor what they may do (@rola, @dostęp), but a credential the
person may present. Four properties define it:
- Hand-authored grant, daemon-minted secret. No daemon write ever touches this
file:
POST /api-keysmints the rawnak_value into a private one-time stash and returns the ready-to-paste- @klucz_api: sha256:<hash>line plus a claim path; the raw value is retrievable exactly once fromGET /api-keys/claim/<sha>(the entry self-destructs on first read), and the human pastes the line and commits. The store only ever carries the hash — never the raw value — and the minter never sees the key at all: only its claimant does. - Fleet-wide by construction. The grant rides the same sync that makes a
@rolaelevation fleet-wide, so validity and revocation are eventual — one sync interval per host; the window's numbers and the whole auth story are Permissions'. - Identity, not capability. The tag carries no capability list: presenting the
key resolves the person, and the key's power is the fold above over every role
the person holds, recomputed per request — editing any
@rolaline or@dostępretunes every key the person holds, after sync, with no re-mint. (A named token, by contrast, snapshots its capabilities at mint.) - Rotation is multiplicity. Several
@klucz_apitags may coexist under one person: add the new hash, roll the client, delete the old line — no gap. A tag may carry an optional(from:to)validity range — the standard entity temporal grammar, either side open, To at UTC day-end; the byte-shape is the contributor store reference's. - Main-branch only. A host serving the store from any ref other than its sync
branch — a side branch, a detached HEAD — refuses every
nak_bearer until the checkout returns: a key answers only to the fleet's converged truth. - Any owner: person or role. A
@klucz_apitag is equally valid inside a## Roleblock — the key then wields the role's live caps with no person behind it (no Margonem id, no Gracz, every.ownscope refuses; audit saysapi:rola:<label>). One hash under two owners of any kinds is ambiguous and resolves for nobody.
The moderators roster rides the same model¶
Moderators authenticate through this store's machinery, from their own file: the
contributor model carries the moderator roster (nerthus.moderators.md, read from the
second working tree configured at corpora.mc), so a moderator's @klucz_api resolves
in Resolve-NerthusApiKey with their mc/smc bundle and no gracz. The roster's
people are carried, never merged into ## Osoby: a chat moderator who has never
played appears in no roster view and projects no Gracz. A key hash claimed in both
files is refused in both (DuplicateApiKey), because an ambiguous credential would
authenticate one person under another's block — the repair is minting a new key.
How the close reminder sources channels¶
The close reminder reads its Discord channel names from committed lore only, and
all of it is in this file. The narrator's own channel is one parameterized lookup for
purpose narratorzy: their @discord purposed narratorzy, else their unpurposed
default slot, else the shared narratorzy role's @discord here. PU and @Intel run
the same lookup asking for gracz instead. The shared Rada channel is the rada role's
@discord. The three-step chain this replaces existed only because a person's two
channels lived in two blocks in two files with no way to say which was which.
Resolve-NerthusCloseReminderTargets nudges each open session to its narrator's channel
plus the Rada channel, and a narrator who does not resolve to a player reaches the Rada
channel only (Sessions). Every channel is a committed name, so a
CI clone resolves the whole target set with no private channels.json and no channel
map in the request body — CI and server mode call it identically. Posting to a resolved
name, though, needs the Discord bot token, the one out-of-band secret the warning below
covers.
Security tradeoff — a bot token
The @discord channel names in nerthus.contributors.md — every person's and
every role's — are ordinary committed lore; a channel name is not a secret.
Posting to a channel by name, however, needs a Discord bot token, and that token
is a genuine out-of-band secret a per-channel webhook never required: it lives
host-local in gitignored .nerthus/local/discord.json (or the masked CI variable
DISCORD_BOT_TOKEN). It is also broader — a bot can post to every channel it
can see, where a webhook reached exactly one — so this is a deliberate scope tradeoff
for operator simplicity plus a read-privacy win: the bot is write-only (View Channel
+ Send Messages, no Read Message History, no Message Content intent), so it never
reads a player's channel. Delivery detail is the logs model's.
Validation and self-heal¶
Validation is read-side and non-fatal — the reader records findings and keeps
going, exactly as Test-NerthusEntityIntegrity does for the entity index. A block
missing @margonemid is flagged (MissingMargonemId) and left unbound; a block whose every
@margonemid range has closed is flagged (RetiredMargonemId) and projects nothing; an unknown
@rola, an unknown capability, a duplicate person or role, or an unrecognized tag is
reported too. The roster and purpose classes join them: a person holding a role for
which no @discord names a channel (MissingDiscord), a @discord purposed for a role
the person does not hold (DiscordPurposeWithoutRole), a malformed or duplicate-purpose
@discord (MalformedDiscord), and a ## Gracze section left behind in
nerthus.entities.md (RosterInIndex). A @klucz_api grant validates fail closed: a raw nak_ paste, a
malformed hash, a malformed expiry date, or a hash claimed by two different people is
a finding and the key resolves for nobody. The finding set, with severities, is the
contributor store reference's, and it is queryable over
HTTP at GET /contributors/integrity.
The store is a committed file, so the daemon's fingerprint self-heal rebuilds the
contributor model on the next request after any hand edit or git pull, without a
restart — the data-owner and self-heal contract is the architecture's.
Cmdlet surface¶
The store is hand-authored: no cmdlet mints or edits it, and no route regenerates
it. The daemon reads it internally on every request. The related client cmdlets are
thin Verb-Nerthus* wrappers; routes, parameters, and envelopes are in the
API reference.
- Mints a grant line:
New-NerthusApiKey— a thin wrapper ofPOST /api-keys: the daemon generates the rawnak_value into its one-time claim stash and the cmdlet returns the ready-to-paste@klucz_apiline plus the claim path (Receive-NerthusApiKeyperforms the single pickup); the operator pastes the line and commits — this file is still never machine-written. - Consumes the store:
Send-NerthusCloseReminder— nudges open sessions to the Rada channel this store's## Role@discordsupplies and the narrator channel the person block's@discordpurposednarratorzysupplies. - Reflects the resolved set:
Get-NerthusWhoami,Get-NerthusCapabilityreport a caller's effective capabilities after the fold above.
Example¶
The store below holds three people and defines the roles they hold. Stefan
(Margonem 100001) plays and narrates — he owns the Postać Eraster and narrates as
him — so he is one block with two roles and two purposed channels; Roman
(Margonem 100002, who owns the Postać Lord Tussal) plays and sits on the Rada, with a
personal @dostęp narrowing one stem; Dorota is the Namiestnik and no player:
## Osoby
### Stefan
- @margonemid: 100001
- @status: Aktywny
- @rola: gracz
- @rola: narratorzy
- @discord: gracz-stefan, gracze, gracz
- @discord: narrator-stefan, narratorzy, narratorzy
- @dostęp: currency.write
- @klucz_api: sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
- @klucz_api: sha256:60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 (:2027-01-31)
### Roman
- @margonemid: 100002
- @status: Aktywny
- @rola: gracz
- @rola: rada
- @discord: gracz-roman, gracze, gracz
- @dostęp: session.write.own
### Dorota
- @margonemid: 100003
- @rola: namiestnik
- @discord: kanal-namiestnika, rada, namiestnik
## Role
### narratorzy
- @dostęp: entity.read, player.read, session.read, session.write, session.report, pu.award, pu.read, governance.read, currency.read, log.read, log.fetch, discord.send, events.subscribe
- @discord: powiadomienia-narratorow, narratorzy
### rada
- @discord: powiadomienia-rady, rada
Resolving Stefan: the gracz token bundle, unioned with both his roles — gracz
adds nothing the token did not already carry, narratorzy adds its @dostęp
(session.write, pu.award, discord.send, …) — plus the personal currency.write,
which adds coin authority no baked narrator bundle carries. Resolving Roman: the union
of gracz and rada (the latter mapping to the radny bundle, whose ## Role block
carries no @dostęp, so the baked bundle stays); his personal session.write.own then
overrides that union on the session.write stem — once, last — so Roman may edit
only his own sessions while keeping every other Radny capability. Ordering matters here
and is Permissions's. Dorota's namiestnik label resolves
to admin.all; carrying no @rola: gracz, she is not a player at all, so every
.own scope refuses her.
Stefan's two @klucz_api lines are his API keys. Presenting the raw value behind the
first hash authenticates as Stefan on every fleet host, with exactly the effective set
computed above. The second line is his rotation-in-progress: it keeps resolving through
2027-01-31 (UTC day-end) and stops the day after, while the first line carries on —
no gap.
When the reminder runs against a Thuzal session signed by Eraster — Stefan's Postać —
it resolves that character to the Gracz Stefan and asks for purpose narratorzy,
which matches his second @discord line and nudges narrator-stefan (had he carried no
purposed entry, the lookup would fall to his unpurposed default slot, then to the shared
narratorzy channel), plus the rada role's @discord. His player notifications go
to gracz-stefan instead, from the same block, because PU and @Intel ask for gracz.
A session narrated by Dorota, who is no player, reaches the Rada channel only.
See also¶
- Architecture — the data-owner contract,
.nerthus/layout, and the fingerprint self-heal this store rides - Contributor store reference — the closed tag tables, the label⇄bundle map, and the validation findings
- Capabilities reference — the capability id list and the baked role bundles the store overrides
- Permissions — tokens, identity, the API-key auth story,
the capability ACL, and
roles.jsonas the fallback seed - Player model — what a player record means, and the
Graczblock projected out of this store's@rola: graczpeople - Session model — close-state and the reminder that consumes these channels