Skip to content

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 ## Osoby section, one ### <Osoba> block per human. This model owns the block's authorization tags: any number of @rola labels, optional personal @dostęp capabilities, any number of purposed @discord channels, and any number of @klucz_api API-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: gracz is what makes somebody a player: the daemon projects one Gracz record 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: @ranga carries the old narrator table's Ranga cell verbatim, and @uprawnienie one permission ceiling per line. Both are deliberately redundant beside @rola and @status, because /governance/narrator-signals reconciles 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 @discord names 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 ## Role section, one ### <etykieta> block per role. Each block carries the role's @dostęp capabilities and its shared @discord Discord channel (a channel, category name pair the bot resolves and posts to by name). For instance, the read-only IT role carries no @dostęp, so it keeps its baked it bundle and only names its shared channel kanal-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ęp capability replaces the role on its <resource>.<action> stem: personal session.write.own narrows the role's session.write to own-scope on the same stem.
  • API key (@klucz_api) — a long-lived credential grant inside a person's block. The tag stores only the sha256: of a nak_ 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 @rola at all is elevated by nothing. They are in the file and they are nobody in particular.
  • A ## Role block for gracz carrying @dostęp retunes 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:

  1. the session-token role bundles — always just gracz;
  2. the contributor store — the person's @rola role caps, then their personal @dostęp overriding the role per <resource>.<action> stem;
  3. the moderators roster (nerthus.moderators.md) — the mc/smc bundle of every @rola whose range covers now, unioned in;
  4. 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:

  1. Hand-authored grant, daemon-minted secret. No daemon write ever touches this file: POST /api-keys mints the raw nak_ 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 from GET /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.
  2. Fleet-wide by construction. The grant rides the same sync that makes a @rola elevation fleet-wide, so validity and revocation are eventual — one sync interval per host; the window's numbers and the whole auth story are Permissions'.
  3. 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 @rola line or @dostęp retunes every key the person holds, after sync, with no re-mint. (A named token, by contrast, snapshots its capabilities at mint.)
  4. Rotation is multiplicity. Several @klucz_api tags 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.
  5. 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.
  6. Any owner: person or role. A @klucz_api tag is equally valid inside a ## Role block — the key then wields the role's live caps with no person behind it (no Margonem id, no Gracz, every .own scope refuses; audit says api: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 of POST /api-keys: the daemon generates the raw nak_ value into its one-time claim stash and the cmdlet returns the ready-to-paste @klucz_api line plus the claim path (Receive-NerthusApiKey performs 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 @discord supplies and the narrator channel the person block's @discord purposed narratorzy supplies.
  • Reflects the resolved set: Get-NerthusWhoami, Get-NerthusCapability report 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.json as the fallback seed
  • Player model — what a player record means, and the Gracz block projected out of this store's @rola: gracz people
  • Session model — close-state and the reminder that consumes these channels