Skip to content

Capabilities

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 capability ACL: the capability id grammar, the capability list, full/own/none grading, the seeded role bundles, and a summary of which routes demand which capability. The identity model behind it — tokens, Margonem-ID binding, identity linking, governance data — is owned by Roles & permissions. Each API reference area page carries the route table for its own slice, naming every route's exact capability and Write flag.

The list is derived rather than declared here. Nerthus.Core builds it from the closed route table plus the role bundles, tops it with admin.all, and publishes the result as the capabilities field of GET /schema. The contributor store's read side runs the same derivation over the seeded bundle defaults, so a ## Role block cannot widen the set its own @dostęp is checked against. With either input missing — a test parsing a store on its own — that validator falls back to a list in code carrying the same ids. One id below stays outside the derivation: log.fetch.request, which the fetch service checks against the caller's set itself. Everything else becomes authorable in the contributor store as soon as its route or its bundle exists, so the table below records that set rather than defining it.

Id grammar

A capability is <resource>.<action> with an optional .own ownership suffix:

<resource>.<action>[.own]     e.g. entity.read, pu.award, player.write.own
  • <resource>.all grants every action on the resource.
  • admin.all grants everything — it is the machine token's implicit set and the Namiestnik/Koordynator bundle.
  • X.own never silently satisfies X (see Grading).

Capability list

Capability Grants
entity.read Read any entity, name resolution, location/map reads and job polling — the broad shared-lore read. It also carries the lore-file read surface: the file tree, a file's prose and an entity's own @plik target. The dated session blocks inside a lore file are not in it — those cost session.read, so a reader without it is served section=head and refused the rest with 403 SessionsRestricted
entity.write Create/modify/soft-delete entities, maps, and traversal links. It also carries one confirmation path — accepting a computed alias into the index — because its seeded population is the Radny bundle and admin.all and nothing else, which is the body that rules on the books. Ruling on a labelled sample left it for sample.confirm on 2026-09-03
sample.confirm Rule on a labelled samplePOST /training/samples/confirm and /confirmations: affirm, deny or correct a proposed row, which writes the sample store and nothing in the entity index. Carried by Narrator and Radny since ruling H123.3 (2026-09-03, 123-ner-plan/HUMAN.md): any narrator may confirm a span or a session-scoped epithet binding from the close-session screen, and the price of that act is this capability rather than entity.write
lore.edit Open an editing session, stage pending changes, and publish them as lore (the editing plane). Not entity.write: that authorises one - @tag: value line through a primitive that cannot express anything else, while these routes accept arbitrary file text
docs.edit Edit a configured non-lore document target in another repository (daemon/services/Edits.ps1). It cannot be granted today. It is enforced below the route layer, so it is in no route's capability and in no baked bundle — and Get-NerthusCapabilityUniverse derives the grantable set from exactly those two plus admin.all, so Test-NerthusKnownCapability -Capability docs.edit answers False and an @dostęp: docs.edit line is refused by the validator. log.fetch.request is in the same position. Project 92 owns the fix, because widening that universe changes what the contributor store accepts as a grant — an authorization surface rather than a documentation one. Until it lands, admin.all is the only set that satisfies either
player.read Read Gracz records and their Postacie (/people, /characters reads)
player.restricted.read Read a person's Tematy zastrzeżone — the subjects a Narrator must not put in front of them — wherever a projected Gracz carries them. It gates a FIELD, not a route: /entities/{name} stays entity.read, and the field is removed from every response a caller without this capability receives, on every route, with no query parameter that puts it back. A Gracz does not hold it, and neither does a Bot. It is also derived: a caller whose set already carries pu.award, lore.edit or moderation.read holds it, so a committed roles.json that predates the capability does not silently take a Narrator's planning surface away
player.write / player.write.own Postać CRUD on the dedicated /characters routes (.own = the caller's own subjects). The roster is hand-authored, so no route these gate touches a Gracz: every /people write answers 422 naming nerthus.contributors.md (API reference). player.write.own no longer lets a player set their own Discord channel — that is an edit to the store and a commit (roster model)
player.declare.own Reserved for the bot's /deklaracja self-service declaration; seeded in the Gracz bundle, no route consumes it yet
session.read Session reads, integrity, participation graph, narrator profile. It also gates the same bytes wherever else they surface: the session search tier and the session blocks inside a lore file (Lore files). Not in the Gracz bundle — a write-up carries what the narrator told the table and what was withheld from it, so being present at an evening is not a claim on its record
session.write Author/edit/open sessions, rebuild the graph, refresh the hash store
session.close Enact a close (POST /workflows/close-session, /workflows/close-session-by-discord) — distributes, moves currency, delivers intel; gated apart from author/edit. Ownership (own sessions, or any for a Radny) is enforced in-handler
session.report File a session as a self-service draft under zgłoszenia/ (POST /sessions/report, /workflows/report-session-by-discord) — never distributes, closes, or awards PU. The Discord delegate additionally publishes the accepted draft as a merge request
session.distribute Run entity-driven session distribution
pu.award Run the monthly PU batch (POST /workflows/award-pu)
pu.read / pu.read.own PU timeline/history/new-character preview and election eligibility (.own scopes the timeline, new-character preview, and eligibility to own Postacie / own Gracz; history is served unscoped)
workflow.settle Run the settlement workflow (POST /workflows/settle) — see Settlement model
workflow.map_checkup Run the map-checkup sweep (POST /workflows/map-checkup), and the unbounded catalogue form of POST /maps/dimensions. Measuring a named set of at most 50 maps costs location.override instead — the same gate as saving a world-grid layout, because the role that lays out the world is the one that needs its maps sized
discord.dispatch Accept a worker-dispatched close interaction (POST /discord/dispatch) — held by the ingest Worker's minted token; in no role bundle (the workflow.settle precedent)
fleet.read Read the aggregated peer observations (GET /fleet/peers, GET /fleet/hosts/{town}) — a who-is-dark map is reconnaissance; in no role bundle
fleet.takeover Request an apex role-flip proposal (POST /fleet/takeover) — operators and the monitor; in no role bundle, and deliberately NOT on the fleet peer token
fleet.announce Deliver a topology hint (POST /fleet/announce) — the fleet peer token's write half; powerless by design (record + early sync only); in no role bundle
derive.read Copy a derived cache file another host already built (GET /derived, GET /derived/{kind}) — the fleet peer token's third read; reaches a closed list of derived files and no lore, no state, no ledger; in no role bundle
session-registry.regenerate Repair the derived Lokacje/ registry (POST /session-registry/regenerate); adoption materializes it and every model rebuild refreshes it
currency.read / currency.read.own Holdings, reports, denominations (.own filters to own holdings; reconciliation needs full)
currency.write Create/adjust holdings; apply session @Transfer directives
economy.read Economy snapshot, timeline, materialization
governance.read Read the narrator Uprawnienia table
governance.write Edit narrator Uprawnienia rows
location.override Reindex the repository after hand edits to nerthus.entities.md, place maps on the world grid (POST /maps/layout), and measure a named set of at most 50 map renders (POST /maps/dimensions) — Rada-level (see Locations model)
identity.read GET /auth/whoami, GET /identity/{id}
identity.link Link a Discord account ⇄ Margonem ID
capability.read GET /capabilities (the caller's own effective set)
capability.admin Grant/revoke per-Person capabilities
token.manage Mint/list/revoke named tokens
apikey.manage / apikey.manage.own Mint api keys (POST /api-keys) for a same-or-weaker person; the .own narrowing mints for the caller's own id only, and is the Bot bundle's self-rotation grant
log.read Log streams, audit reads, entity history, Discord delivery log, log parsing, and the labelled-sample store, whose rows quote those same transcript bytes
external.read Repozytorium Dzieł, the works repository: the external search tier and the Dzieła root on the lore-file read surface. Not in the Gracz bundle — a player's own work is quotable inside the fiction and is not something every other player may search. There is no external.write: the works repository is written through git, by whoever holds its keys. A repository adopted before this capability existed grants it to nobody until the Rada adds it to roles.json — see the note under the bundles below
log.fetch Game-log fetch and per-session refresh, inside the integrations.logs.hosts allow-list
log.fetch.request Request-mode fetch: a caller-supplied HTTP request issued from the daemon's own network position
discord.send Post a message to a Discord channel (the bot resolves the channel by name)
events.subscribe Open the GET /events SSE stream (Bearer in the header)
sync.read Read the daemon's repo-sync state (GET /sync)
keyring.read List the moderation keyrings and read one's membership and epochs (GET /keyrings, GET /keyrings/{name}). Seeded in the mc and smc bundles. It reads who may open a record, never a record's contents: the bodies are ciphertext addressed to the keyring, and the daemon is not a member of it
keyring.admin Publish a new keyring epoch and settle key claims (POST /keyrings/{name}/epochs, POST /keys/claims). smc alone. Publishing an epoch changes who can open every record sealed after it, which is why it is not the mc bundle's
moderation.read The moderation register's read surface — records, one record, disclosures, the transparency roll and the moderator roster (7 routes under /moderation/* and /moderators). Seeded in mc and smc. Again the record body is not in it
moderation.write File a moderation record (POST /moderation/records). mc and smc
moderation.admin Remove a record's key wraps (DELETE /moderation/records/{id}/wraps). smc alone — it is the escalation authority, and unwrapping is how a record stops being openable
moderation.export Export the moderation corpus (GET/POST /moderation/exports). In no bundle at all, smc included, and deliberately: reading one record is somebody doing the job, exporting the corpus is a different act and reaches only through admin.all or a per-Person grant
regulation.write Publish a new version of a regulation (POST /regulations/{doc}/versions). smc alone. An amendment changes the text every future sanction is judged against and every past one contested against, so it sits with epoch publication rather than with issuing a sanction
sejf.read Fetch the credential estate's inventory and the age-encrypted blobs beside it (GET /sejf/inventory, GET /sejf/blob). Its own id rather than a widening of keyring.read: that one decides which bytes of the moderation corpus the daemon hands over, this one decides who may read the map of the estate's credentials. It never yields a key — the values are age-encrypted to recipients and the daemon holds no identity, so it serves ciphertext for every path but the cleartext inventory. In no bundle: the Rada grants it per Person with a - @dostęp: sejf.read line, and admin.all satisfies it meanwhile
recovery.read Read and stage disaster-recovery documents (GET/POST /recovery/documents). In no bundle — its audience is whoever is restoring the estate, which is orthogonal to every role here, so it is a per-Person grant or admin.all
gitlab.act Replay a GitLab job or retry a pipeline through the daemon (POST /gitlab/jobs/{id}/play, POST /gitlab/pipelines/{id}/retry). In no bundle
gitlab.trace Read a GitLab job's trace through the daemon (GET /gitlab/jobs/{id}/trace). In no bundle, and separate from gitlab.act because reading a trace and re-running a job are different permissions
sync.run Run a repo-sync tick immediately (POST /sync)
admin.mode / admin.shutdown / admin.index / admin.migrate Control-plane: mode flip, shutdown, name-index rebuild, repository import (POST /import). Adoption also materializes the Lokacje/ registry, so admin.migrate performs that write without session-registry.regenerate
admin.all Everything (machine token, Namiestnik/Koordynator)

entity.read is wider than the entity index. Its file half reaches an allowlist of eight lore roots and seven root-level files — Świat gry, Postaci, Organizacje, Wątki, Bestiariusz, Archiwum, Źródła and the generated Lokacje (daemon/services/Files.ps1). A ninth root, Dzieła, appears only for a caller holding external.read, and is not entity.read's. One of those files is nerthus.contributors.md, the store carrying every person's @dostęp grants and the SHA-256 behind each @klucz_api line, so every seeded bundle holding entity.read can page it: Gracz, Narrator, Radny, IT and Bot. A stored hash is not a usable bearer. The allowlist and the refusals are Lore files; the reasoning is the lore-file read surface.

A log.fetch fetch reaches only the hosts integrations.logs.hosts names, and the daemon re-checks that list against every redirect hop before it follows one. log.fetch.request adds the kind: request form, where the caller supplies the method, URL, headers and body and the daemon issues that request past the allow-list. POST /logs/fetch and POST /logs/parse both reach that form, so the fetch service checks the capability at its one outbound call rather than at the route. A fetch persists the answer into the committed nerthus.logs/ archive; parse returns it and writes nothing.

No seeded role bundle carries workflow.settle, workflow.map_checkup, session.distribute, session-registry.regenerate, economy.read, log.fetch.request, or sync.run — the read-only IT bundle is the only one that carries sync.read. Settlement spans distribution, currency, and PU at once; distribution, registry regeneration and sync act on the whole repository; and log.fetch.request reaches whatever the daemon's network reaches. Those seven resolve only through admin.all or a per-Person grant. .own grading is meaningless for repo-level sync: the handlers treat an own-only sync grant as no grant and refuse with 403. The daemon's own scheduled sync tick runs in-process and needs no token — design in Sync.

The machine token implicitly satisfies every capability (admin.all); it exists to authenticate local processes, never to sandbox the operator's shell. Capabilities scope end users reaching the daemon through the Discord/Margonem bot — token mechanics in Roles & permissions.

Grading (full / own / none)

When the dispatcher checks a route's required capability, the caller's grant is graded:

Grade Condition Effect
full Holds the plain capability, a matching .all, or admin.all Unrestricted handler run
own Holds only the .own variant Request marked OwnOnly — the handler scopes it to the caller's own subjects or refuses
none Neither 403 naming the missing capability

X.own never silently satisfies X. The caller's own subjects are their Gracz entity (by name or Margonem ID) and every Postać whose @należy_do is that Gracz.

Handler scoping for an OwnOnly caller, route by route:

Route (OwnOnly caller) Scoping
GET /pu/timeline Filtered to own Postacie; an explicit ?character= must be an own subject (403 otherwise)
GET /pu/new-character-count player is forced to the caller's own Gracz
GET /pu/history Served unscoped — each row carries only a batch date and a counted-session count, facts the committed nerthus.ledger.md echo already publishes to every repo clone
GET /elections/eligibility gracz is forced to the caller's own Gracz (the /wybory self-check)
GET /currency, GET /currency/report Filtered to holdings owned by the caller's Gracz/Postacie
GET /currency/{name} 403 unless the holding is the caller's own
GET /currency/reconciliation Always 403 — reconciliation is a repo-wide integrity report
PATCH/DELETE /people/{name}, /characters/{name} 403 unless the subject is the caller's own
POST /people Always 403 — a new Gracz has no ownership to scope to
POST /characters @należy_do must equal the caller's own Gracz

So a Gracz holding pu.read.own reads their PU timeline but not another player's, and player.write.own lets them edit their own Postać but not create a second Gracz.

Role bundles

Roles are named bundles of capabilities — data, not hardcoded tiers. They load from committed .nerthus/data-tables/roles.json; the daemon seeds that file only when it is absent, so a Rada edit survives every boot and re-init (file location and seed rule: Data tables, Configuration). The seeded defaults are also the fallback when the file is missing:

The committed file replaces these; it does not merge over them

A capability added to the bundles below reaches a newly adopted repository, because the seed is generated from them. It reaches an already-adopted one not at all: that repository already has a roles.json, the seeder writes only when the file is absent, and the resolver takes the file wholesale. Until the Rada adds the key to the table, the capability is held by admin.all alone.

That is the right behaviour — a merge would silently re-grant whatever the Rada had removed on purpose — but it means the table below is what a fresh repository gets, not what any given host enforces.

And the edit is two steps, not one. A named token freezes its capability set when it is minted, so adding a key to roles.json changes nothing for anybody already holding a token — measured: the same narrator token answered identically either side of the edit, across a daemon restart, and only a token minted afterwards saw the change. Re-mint the affected tokens, or the grant is invisible to every principal that has one.

Role Bundle (as seeded)
Gracz entity.read, player.read, player.write.own, session.report, pu.read.own, currency.read.own, player.declare.own, events.subscribe, identity.read, capability.read
Narrator entity.read, player.read, player.restricted.read, session.read, session.write, session.close, session.report, pu.award, pu.read, governance.read, currency.read, log.read, log.fetch, external.read, discord.send, events.subscribe, lore.edit, sample.confirm
Radny entity.read, entity.write, player.read, player.restricted.read, player.write, session.read, session.write, session.close, session.report, pu.award, pu.read, governance.read, governance.write, token.manage, apikey.manage, capability.admin, identity.link, log.read, external.read, currency.read, currency.write, location.override, discord.send, events.subscribe, lore.edit, sample.confirm
Namiestnik / Koordynator admin.all
IT entity.read, sync.read, log.read, log.fetch, discord.send, events.subscribe, capability.read
Bot entity.read, player.read, session.read, pu.read, currency.read, events.subscribe, discord.send, identity.read, capability.read, apikey.manage.own
MC player.read, player.restricted.read, identity.read, capability.read, events.subscribe, keyring.read, moderation.read, moderation.write
SMC the MC bundle plus keyring.admin, moderation.admin, regulation.write

A player cannot read another player's Tematy zastrzeżone. Until 2026-08-26 they could: the field rides on the Gracz projection, /entities/{name} costs entity.read, and every player's token holds it — measured through the API, not inferred. Moving the field behind player.read was proposed and would have changed nothing, because the Gracz bundle holds player.read too. player.restricted.read is the capability that actually discriminates, and the removal happens in the dispatcher for every route rather than in the handlers that happen to know about it.

The Gracz bundle does not read sessions, including the evenings that player was at. The capability governs three doors and each one is gated on it, because the same bytes reach a reader through all three: the /sessions routes, the session search tier, and the session blocks inside a lore file served by /files/content. session.report stays in the bundle — filing an account of an evening is not reading the account somebody else filed.

The MC and SMC bundles do not read the lore. Chat moderation is a job over people and records: those surfaces read /moderation/*, /keyrings/* and the regulation corpus, which needs no bearer at all. Somebody who moderates chat and narrates holds the lore through the narrator role, since a person's effective set is the union of the roles they hold. Neither bundle can read a moderation record's body: it is ciphertext addressed to the moderacja keyring, which the daemon is not a member of.

Namiestnik and Koordynator are two distinct roles with identical seeded bundles — roles.json keys them separately, so the Rada can diverge them later. The bundles are independent lists, not strict supersets: the narrator bundle carries full pu.read/currency.read instead of the .own variants and omits identity.read; the radny bundle omits log.fetch.

A person may hold several roles at once — somebody who plays and narrates holds both — and their effective set is the union of those bundles, with their personal @dostęp applied once, last, over that union. The resolution order is owned by Permissions; the examples below assume a single role only for brevity.

A Person's effective set is built in layers: their role bundles (usually just gracz), then their contributor-store elevation — the @rola role's caps plus their personal @dostęp, from nerthus.contributors.md — then per-Person grants, minus per-Person revokes (a revoke wins last). Session tokens store only the role; capabilities are recomputed on every request, so a change takes effect on live tokens immediately. Every externally-minted session token carries the gracz role, so a Narrator's or Radny's extra powers come from the contributor store (their @rola), not the token; a per-Person grant on their Margonem ID or a named token minted with -Roles narrator are the secondary paths. Because session.report sits in the gracz bundle, every logged-in player can file a session report (POST /sessions/report) without any per-Person grant — the draft is quarantined and Rada-reviewed, so the capability is safe to seed broadly (Submit a session report).

Routes → capabilities (summary)

Route by route, the exact capability and Write flag are on the API reference area page owning that slice. The shape of the mapping:

Route area Read Write / act
/entities, /resolve, /locations, /maps, /regions, /jobs entity.read entity.write
/files, /files/content, /entities/{name}/file entity.read, plus session.read for a file's session blocks and external.read for the Dzieła root
/training/samples log.read entity.write (confirming a sample)
/locations/reindex location.override
/people, /characters player.read player.write (.own graded) — the /people write half answers 422
/sessions, /workflows/open-session session.read session.write
/workflows/close-session, /workflows/close-session-by-discord session.close
/sessions/report, /workflows/report-session-by-discord session.report (self-service draft)
/workflows/query-by-discord entity.read
/workflows/distribute-session session.distribute
/pu/*, /elections/eligibility pu.read (.own graded) pu.award (the monthly batch)
/currency/*, /workflows/apply-transfers, /audit/ledger currency.read (.own graded) currency.write
/economy/* economy.read
/workflows/settle workflow.settle
/workflows/map-checkup workflow.map_checkup
/maps/dimensions location.override (named, ≤ 50) / additionally workflow.map_checkup (catalogue sweep)
/session-registry/regenerate session-registry.regenerate
/logs/{stream}, /logs/parse, /audit/changes, /audit/notifications, /entities/{name}/history, /discord/deliveries log.read
/logs/fetch, /logs/import, /sessions/{header}/logs/refresh log.fetch
/discord/send discord.send
/governance/permissions governance.read governance.write
/auth/whoami, /identity/{id} identity.read identity.link (POST /auth/link)
/capabilities capability.read capability.admin (grant/revoke)
/tokens token.manage token.manage
/api-keys apikey.manage (GET /api-keys/claim/{sha} is public, one-time)
/sync sync.read sync.run
/events events.subscribe
/admin/mode, /admin/shutdown, /name-index/rebuild, /import admin.mode / admin.shutdown / admin.index / admin.migrate

Eight routes are public (no bearer, no capability): GET /health, GET /schema, GET /routes, GET /schema/version, GET /fleet/status, POST /auth/margonem, POST /auth/discord, and the one-time GET /api-keys/claim/{sha} — there the 64-hex sha in the path is the secret, and the entry destroys itself on first read. GET /fleet/status answers a fixed self-description card and nothing else; the field list is contract, owned by Fleet. GET /routes publishes the whole map, so the live count is always one anonymous call away.

Note

A capability never bypasses the write gate. Even admin.all is refused when the daemon is in read-only mode or on schema drift — status codes and error ids in API reference.

Election eligibility (GET /elections/eligibility) requires pu.read because eligibility is PU-derived data; a Gracz self-checks via pu.read.own. The formula and window live in PU model.