Skip to content

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:

## Keyringi

### lore
- @admin: namiestnik
- @czytelnik: namiestnik
- @czytelnik: rada
- @epoka: 1

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:

### IT
- @discord: kanal-wewnetrzny-it, it

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:

  • DuplicatePerson is 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.
  • DiscordPurposeWithoutRole is 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.
  • MissingDiscord is 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ęp accepts 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ęp overrides; a bare ## Role block 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 @discord channel.
  • A personal @dostęp overrides the role per <resource>.<action> stem. A person's session.write.own removes the role's session.write on that stem and adds its own; ids on other stems are unaffected. The resolution order is the model's.
  • The @discord value grammar is kanał, 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 the UnknownRole finding you already know. Arity decides the meaning:

    Value Reads as
    kanał, kategoria, cel the channel for that purpose
    kanał, kategoria the unpurposed default slot — matched when no purposed entry fits. It is explicitly not read as gracz
    four or more components MalformedDiscord; nothing is glued into the category
    BRAK, blank, or one component no channel

    Resolution runs once, in this order: the person's entry for purpose P → their default slot → the ## Role block for P → nothing. The close reminder asks for narratorzy; PU and @Intel ask for gracz.

  • @discord: BRAK means no channel — the reader treats the literal BRAK as an absent channel. Absent is not BRAK. An absent @discord means not yet known and raises MissingDiscord; BRAK means decided: no channel and raises nothing. Do not use BRAK to quiet the list.

  • Several @rola bullets union. A person holds every role they list, and their effective capabilities are the union of those bundles with their personal @dostęp applied once, last, over that union. A role is retired by closing its range (@rola: narratorzy (2020:2024)), never by a status flag — @status is the person's standing in the campaign.
  • The @klucz_api value grammar is sha256:<64 hex>[ (from:to)]. Lowercase hex behind the explicit sha256: 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 raw nak_ value never appears in this file: a value starting nak_ is refused fail-closed (RawApiKeyValue), because the committed store may carry only the hash — the same non-reversible SHA-256 id GET /tokens already exposes for runtime tokens.
  • Keys canonicalize on read. A tag key is lowercased and its one diacritic folded (@Dostęp, @dostep both read as @dostęp); the ## Osoby and ## Role section headings match case-insensitively. Any other ## section is 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.