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.
Lookup reference for the closed tag set, the role-label⇄bundle-key map, and the
validation findings of nerthus.contributors.md — the committed, hand-authored store
of the campaign's people (## Osoby), roles (## Role), and keyrings (## Keyringi). The behavior
behind these tables — how effective capabilities resolve, how the close reminder sources
channels, the committed channel names and the bot-token tradeoff — is the contributor store model.
## Osoby tags¶
One ### <Osoba> block per human — player, Narrator and Radny alike. The recognized
tags:
| Tag | Meaning | Arity |
|---|---|---|
@margonemid |
The person's Margonem profile id. Several lines when the person has changed Margonem account: the old id is retired by closing the (od:do) range on its own line, exactly as @rola retires a role |
multi, temporal, required |
@rola |
A role label the person holds. Several lines union; a role is retired by closing the (od:do) range on its own line |
multi, temporal |
@status |
The person's standing in the campaign (Aktywny / Nieaktywny / Usunięty) — person-level, not per-role |
scalar, temporal |
@dostęp |
Personal capabilities layered on top of the roles | multi |
@discord |
A Discord channel the person is reached on, kanał, kategoria[, cel] — see the grammar below |
multi, temporal |
@tematy_zastrzeżone |
Content triggers the person does not wish to see, verbatim prose | scalar |
@ranga |
The narrator table's Ranga cell, verbatim (Narrator (Radny), Narrator nieaktywny, …). This is the repository's current-state narrator signal — until August 2026 it lived in the public website's Narratorzy.md table, which left with the site. It reads redundant beside @rola and @status and is kept that way on purpose: /governance/narrator-signals diffs it against the Rada's ledger, and a signal re-derived from the other tags would agree with them by construction and stop being a second opinion |
scalar, temporal |
@uprawnienie |
One narrator permission ceiling, as kolumna, wartość (Magia, do 3/5), the column named exactly as the narrator table's closed set spells it: Proste narracje, Zatrudnienia, Magia, Polityka, Walki PvP, Sesje sprawdzające. A column outside the set is refused rather than stored — an unrecognised ceiling would read as an absent one everywhere downstream, and absent is the answer that lets someone run a session they were never cleared for. A ceiling is retired by closing the (od:do) range on its own line, exactly as @rola retires a role |
multi, temporal |
@alias |
Nick history | multi |
@klucz_api |
An API-key grant — the SHA-256 of a nak_ bearer presentable on any fleet host; valid in a person block AND in a ## Role block |
multi, temporal |
@klucz_szyfr |
The person's public age X25519 recipient key — age1 followed by 58 bech32 characters. Anything may encrypt to it; only the private half, generated on the person's device and never transmitted, decrypts |
multi, temporal |
@klucz_podpis |
The person's public ssh-ed25519 signing key — a moderation record is signed with the private half and verified against this |
multi, temporal |
Deliberately absent, each raising UnknownTag: @typ (the section states the type),
@plik (a person is not a session-distribution target), every pu_* key (PU is the
Postać's), and @prfwebhook (the webhook delivery model is retired — a Discord address
is a channel name).
A block carrying @rola: gracz is projected into the entity model as one Gracz
record; that tag, not a second block anywhere, is what makes a person a player
(the roster model).
## Role tags¶
One ### <etykieta> block per role, headed by the role label. The recognized tags:
| Tag | Meaning | Arity |
|---|---|---|
@dostęp |
The role's capabilities | multi |
@discord |
The role's shared Discord channel kanał, kategoria, posted to by name. A role serves one capacity by definition, so it takes no purpose |
scalar |
@klucz_api |
An API-key grant held by the ROLE — same value grammar as the person tag | multi |
A role-held key resolves the role's live capabilities (the block's @dostęp
override, else the baked bundle) with no person behind it: margonemId and
gracz are null, every .own scope refuses, whoami reports rola: <label>, and
audit attributes api:rola:<label>. One hash claimed by two owners of any kinds —
two people, two roles, or a person and a role — is a DuplicateApiKey Error and
resolves for nobody.
@klucz_szyfr and @klucz_podpis are person-only, unlike @klucz_api: a
role-held recipient key would be a private half held by several people at once, and a
role-held signing key would sign a record as "the role" rather than as a person — the
attribution a moderation ledger exists for. A keyring reaches a group by naming each
member's own key.
## Keyringi tags¶
One ### <name> block per keyring — a named recipient set. Membership is
role-derived, never a name list: revoking access is closing the role's range at
@rola, so there is no second list to drift. The recognized tags:
| Tag | Meaning | Arity |
|---|---|---|
@admin |
The role whose holders administer the ring. keyring.admin is one capability refined per keyring by this tag, so the admin of one ring cannot reach another. Absent is MissingKeyringAdmin (Error) |
scalar |
@czytelnik |
A role whose holders are members. None at all is EmptyKeyring (Error) |
multi |
@epoka |
The current epoch number | scalar |
The role it may never be a @czytelnik (KeyringIncludesIt, Error): IT operates the
hosts and reads every byte the daemon holds, and ciphertext is the one class that stays
out of reach. A person holding it and mc is a member through mc.
This file declares only the rings the narrator-side roles read — the lore repo's section holds one:
Moderation rings are declared in the moderators roster (nerthus.moderators.md), in
the moderators repository: a declaration says who may read a ring, so keeping the
moderation one here would leave the sanction ledger's read access editable through a
narrator's merge request.
Role label → bundle key¶
An authored @rola label (and a ## Role block heading) maps to one
capability-bundle key. The mapping is config-driven: it comes from
data-tables/roles.json — each role's role is its own label and its
aliases list the collectives — with the table below as the fallback a repo with no
roles.json uses:
| Label | Bundle key |
|---|---|
narratorzy |
narrator |
rada |
radny |
namiestnik |
namiestnik |
koordynator |
koordynator |
gracz |
gracz |
IT |
it |
boty |
bot |
mc |
mc |
smc |
smc |
The bundle keys are also accepted as self-aliases, so @rola: narrator
resolves like @rola: narratorzy. A role added in roles.json — say komendant
with alias komendanci — becomes a valid @rola (and a valid @discord purpose, since
the purpose vocabulary is this same map) with no code change. A label outside the
resolved map is an UnknownRole finding. The bundles each key names are the
capabilities reference's.
The IT label maps to the it bundle key and case-folds to it, so @rola: IT and
@rola: it resolve alike — a self-alias like the singular keys above. Its ## Role
block is bare, declaring only a channel:
so the baked it bundle stays and the block adds only the channel
kanal-wewnetrzny-it (category it).
Validation findings¶
Validation is read-side and non-fatal — the reader records findings and keeps parsing,
like Test-NerthusEntityIntegrity on the entity index. The findings below are readable over
HTTP at GET /contributors/integrity — so the
MissingDiscord to-do list is a call the Rada (or CI) can make, not only an in-process model
field.
| Finding | Severity | Meaning |
|---|---|---|
MissingMargonemId |
Error | An ## Osoby block carries no @margonemid — the person is not bound |
DuplicatePerson |
Error | Two person blocks claim one @margonemid over a shared instant — the later block is dropped |
RetiredMargonemId |
Warning | Every @margonemid on a block has a closed range — the person has left: no Gracz is projected, no live capability applies, and they authenticate as nobody. Their history still resolves |
MissingRole |
Warning | A person block carries no @rola at all — no role elevation applies |
UnknownRole |
Error | A @rola label, a ## Role heading, or a @discord purpose outside the label map |
UnknownCapability |
Warning | A @dostęp id outside the closed capability set |
DuplicateRole |
Error | A ## Role label is defined twice |
UnknownTag |
Warning | A tag key outside the section's recognized set |
MissingDiscord |
Warning | The person holds a role for which neither a purposed @discord nor their default slot names a channel |
MalformedDiscord |
Warning | A @discord value that is neither BRAK nor two or three components — or a second entry claiming a purpose already taken (the first is kept) |
DiscordPurposeWithoutRole |
Warning | A @discord purposed for a role the person does not hold |
RosterInIndex |
Error | nerthus.entities.md still carries a ## Gracze section, or this store still uses the ## Osoby funkcyjne heading — import refuses either way |
RawApiKeyValue |
Error | A @klucz_api value starting nak_ — the raw key pasted instead of its hash; the key is refused |
MalformedApiKey |
Error | A @klucz_api value with a bad hash or a bad (from:to) range — one kind, the finding detail distinguishes; the key is refused |
DuplicateApiKey |
Error | One hash claimed by two different people — both claims refused |
DuplicateApiKey |
Warning | One hash repeated under one person — deduped, the first occurrence kept |
Three of these read oddly until you know why:
DuplicatePersonis an overlap check, not a seen-before check. One id may pass from one person to another — an account changes hands — as long as the two claims share no instant, which is exactly what a closed range states. Claims that do overlap are ambiguous identity, so the later block is dropped whole rather than one of them picked.DiscordPurposeWithoutRoleis a Warning, not an Error, because lore may legitimately lag a role change: somebody stops narrating before their channel line is cleaned up, and the channel still works meanwhile.MissingDiscordis a Warning because it is the expected state of a freshly seeded roster, not a defect. Adoption seeds no channels at all, so every player raises it until the Rada fills theirs in; the list of who still raises it is the Rada's to-do list, and it must stay an honest inventory of unfinished work.
The two DuplicateApiKey severities are deliberate, and deliberately unlike
DuplicatePerson's first-wins rule: a person has a canonical first definition, a
credential does not — an ambiguous hash resolves for nobody, while a repeat under
one person is a harmless paste slip that authenticates the same person either way.
Uniqueness holds across stores too: a @klucz_api hash claimed in both
nerthus.contributors.md and nerthus.moderators.md is refused for every claimant on
both sides — a DuplicateApiKey Error, joined to the same RefusedApiKeys list the
within-store pass fills. Refused rather than resolved by precedence, unlike a keyring
name declared in both files: a credential is not a name, and a rule that quietly picked
one claimant would authenticate one person under another's block. The repair is
minting a new key.
Grammar and caveats¶
The store shares the entity index's grammar — ## section / ### block /
- @klucz: wartość read by Nerthus.MarkdownScanner — but has its own reader and is
never machine-regenerated (the contributor store model).
Precise rules:
@dostępaccepts two packings. One bullet may comma-pack ids (- @dostęp: session.write, pu.award) and the tag may repeat across bullets; both fold into one capability list. Blank tokens between commas are dropped.- A
## Role@dostępoverrides; a bare## Roleblock adds only a channel. When the block carries@dostęp, that list replaces the role's baked bundle entirely — list every capability the role should have. When the block omits@dostęp, the baked bundle stays and the block has only declared a@discordchannel. - A personal
@dostępoverrides the role per<resource>.<action>stem. A person'ssession.write.ownremoves the role'ssession.writeon that stem and adds its own; ids on other stems are unaffected. The resolution order is the model's. -
The
@discordvalue grammar iskanał, kategoria[, cel], with the standard trailing(od:do)range stripped first.cel— the purpose — names the capacity the channel serves and is drawn from the role-label vocabulary above, so the label table serves two jobs and a typo'd purpose is theUnknownRolefinding you already know. Arity decides the meaning:Value Reads as kanał, kategoria, celthe channel for that purpose kanał, kategoriathe unpurposed default slot — matched when no purposed entry fits. It is explicitly not read as graczfour or more components MalformedDiscord; nothing is glued into the categoryBRAK, blank, or one componentno channel Resolution runs once, in this order: the person's entry for purpose P → their default slot → the
## Roleblock for P → nothing. The close reminder asks fornarratorzy; PU and@Intelask forgracz. -
@discord: BRAKmeans no channel — the reader treats the literalBRAKas an absent channel. Absent is notBRAK. An absent@discordmeans not yet known and raisesMissingDiscord;BRAKmeans decided: no channel and raises nothing. Do not useBRAKto quiet the list. - Several
@rolabullets union. A person holds every role they list, and their effective capabilities are the union of those bundles with their personal@dostępapplied once, last, over that union. A role is retired by closing its range (@rola: narratorzy (2020:2024)), never by a status flag —@statusis the person's standing in the campaign. - The
@klucz_apivalue grammar issha256:<64 hex>[ (from:to)]. Lowercase hex behind the explicitsha256:scheme (the value is trimmed and case-folded on read, like tag keys); the optional(from:to)suffix is the standard entity temporal range — partial dates allowed (YYYY,YYYY-MM), either side open, out-of-order bounds refused. From is the named day's first instant; To is a UTC day-end expiry: the key resolves through the whole named day and stops the day after. The rawnak_value never appears in this file: a value startingnak_is refused fail-closed (RawApiKeyValue), because the committed store may carry only the hash — the same non-reversible SHA-256 idGET /tokensalready exposes for runtime tokens. - Keys canonicalize on read. A tag key is lowercased and its one diacritic folded
(
@Dostęp,@dostepboth read as@dostęp); the## Osobyand## Rolesection headings match case-insensitively. Any other## sectionis operator prose scaffolding and is ignored.
The capability id grammar and the closed id list are the capabilities reference's; routes and envelopes are the API reference's.