Configuration¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Configure a Platform instance.
Everything that tunes a Nerthus deployment without a recompile lives under .nerthus/ at the repo root. There are three kinds of knob: the daemon config (config.json — read once at boot), the Rada-editable data tables (data-tables/ — read on each use), and the tunable PU constants (the pu block of config.json). The client never reads any of them — it asks the daemon, whose public schema route serves the effective table contents. This page is the lookup contract for file locations, shapes, defaults, and precedence; what each table means is owned by its domain page and linked from here.
Committed vs. private¶
| Location | Tracked | Contents |
|---|---|---|
.nerthus/config.json |
committed | Daemon config + PU constants — Rada policy, reviewable diffs. |
.nerthus/data-tables/ |
committed | Rada lore tables: currency.json, roles.json, towns.json. |
.nerthus/runtime/ |
gitignored | Machine-managed: tokens, discovery files, sync tick stamp. |
.nerthus/local/ |
gitignored | Operator-managed host-local files (sync.json, server.json, discord.json, gitlab.json). |
.nerthus/cache/, log/, state/ |
gitignored | Refetchable machine state, JSONL streams, governance state. |
The full .nerthus/ layout is owned by Architecture. Nerthus never hardcodes a secret into a tracked file. Discord delivery resolves channels by name (@discord tags in the contributor store), so the committed lore holds only channel names — lore, not secrets; the bot token delivery now needs is a host-local secret, never committed. The tradeoff is recorded under Secrets.
.nerthus/config.json¶
One committed JSON object, merged key-by-key over the daemon defaults — an omitted key falls back; an unknown key is merged but nothing reads it. A fresh scaffold writes the seed block rather than an empty object; every other key below is added by hand when the Rada changes a policy, so the file stays diff-friendly. Canonical shape (the values shown are the built-in defaults):
{
"server": { "bind": "127.0.0.1", "port": 0, "read_only": false,
"sync_max_age_min": 60, "sync_branch": "",
"gc_interval_min": 60, "reader_threads": 0, "search_index": "",
"selfheal_interval_ms": 5000,
"mention_index_interval_min": 15, "event_journal": true },
"corpora": { "mc": "", "dziel": "", "adnotacje": "", "probki": "", "logi": "", "lang": "" },
"content_gate": { "sniff_chars": 400, "binary_share": 0.2 },
"scrape": { "pdf_confidence_floor": 0.5, "max_payload_bytes": 20971520 },
"schedules": { "close_reminder": { "owner": "off" },
"map_checkup": { "owner": "fleet" },
"settlement": { "owner": "ci" },
"alias_autopilot": { "owner": "fleet" } },
"alias_autopilot": { "interval_minutes": 30, "max_per_tick": 20 },
"lineage_autopilot": { "interval_minutes": 60, "max_per_tick": 10, "auto_merge": true,
"lore_projects": ["margonem/nerthus/it/repozytorium-fabularne-prodkopia"] },
"integrations": {
"discord": { "enabled": true, "rate_per_minute": 30, "jwks_url": "https://discord.com/api/oauth2/keys",
"client_id": "", "issuer": "https://discord.com", "jwks_cache_ttl_min": 1440,
"guild_id": "", "api_base": "https://discord.com/api/v10", "channel_cache_ttl_min": 60,
"close_reminder": { "first_reminder_delay_hours": 0, "interval_hours": 24, "cooldown_hours": 48 },
"converge_announce": { "enabled": true, "channel": "szary-monitoring", "category": "IT", "max_authors": 5 },
"dispatch": { "window_ms": 5000, "near_deadline_ms": 3000, "skew_alert_ms": 2000,
"max_attempts": 3, "max_per_tick": 1, "tick_budget_ms": 2000 } },
"lang": { "frames_config": "" },
"logs": {
"hosts": ["krisaphalon.ct8.pl", "pastebin.com", "docs.google.com", "drive.google.com", "imgur.com", "wklejamy.pl", "ntpd.eu"],
"fetch_timeout_ms": 15000,
"failed_ttl_hours": 24,
"failed_max_attempts": 5
},
"margonem": {
"profile_url": "https://www.margonem.pl/profile/view,",
"validate_url": "https://public-api.margonem.pl/account/validate",
"signing_key_url": "https://staticinfo.margonem.pl/.well-known/signing-key.pem",
"signing_key_cache_ttl_s": 86400,
"auth_timestamp_skew_s": 300,
"session_ttl_s": 14400,
"map_checkup": {
"interval_hours": 24,
"request_delay_ms": 100, "timeout_ms": 10000, "max_probe_steps": 5,
"failure_budget": 20, "batch_size": 300, "tick_budget_ms": 4000,
"retry_cooldown_ms": 30000,
"hosts": ["micc.garmory-cdn.cloud"]
}
}
},
"sessions": { "staged": false },
"normalization": { "narrator_near_form_score": 0.85 },
"fleet": { "auto_failover": { "after_sweeps": 3, "cooldown_hours": 24 } },
"pu": {
"monthly_base": 1.0, "monthly_cap": 5.0,
"election_window_months": 6, "election_threshold": 3.0,
"new_character": { "divisor": 2.0, "bonus": 20.0, "floor": 20.0 }
}
}
The seeded starter¶
A scaffold with no config.json gets one holding the schedules block and the close-reminder tunables at their defaults. Later boots add any seed block the file is missing and touch nothing else, so a clone made before a knob existed still shows it. Both writes are subject to the write gate, which keeps a replica from handing the publish tick repo dirt to sweep up.
The seed is a subset on purpose. Writing the whole default table into a committed file would pin every default at scaffold time, and the pinned copy would shadow later module defaults for the life of the repo.
A handful of keys the daemon reads have no entry in the default table at all, so the shape above does not show them. Their fallback lives in the consumer that reads them rather than in the merge, and each row below marks them outside the default table.
schedules¶
One owner per recurring workload. The close reminder and the map checkup each exist twice — as the primary's in-daemon tick and as a GitLab schedule — and nothing coordinates the two at run time: the reminder's cooldown ledger is machine-local and the CI pass runs with noDedup, so two live halves mean two nudges per open session. This block is what makes that impossible.
| Value | The in-daemon tick | The scheduled CI job |
|---|---|---|
fleet |
runs it | runs and exits as a no-op |
ci |
inert | runs it |
off |
inert | runs and exits as a no-op |
| Workload | Allowed | Default | Notes |
|---|---|---|---|
close_reminder |
fleet, ci, off |
off |
A fresh clone must not DM anyone before the Rada says so. |
map_checkup |
fleet, ci, off |
fleet |
On shared runners the CI form costs several times the monthly quota (Set up pipelines). |
settlement |
ci, off |
ci |
No in-daemon tick exists, so fleet is refused rather than read as silence. |
lineage_autopilot |
fleet, off |
fleet |
Writes (auto) @grupa / @pochodzenie lines and extracted NPC blocks the format-11 events prove on two sessions and two logs (rulings H126.5 / H127.8, the lineage autopilot). Refuses the production lore by name whatever lineage_autopilot.lore_projects says. |
alias_autopilot |
fleet, off |
fleet |
Writes (auto) alias lines for exact, rival-less computed aliases (ruling H124.1, the alias autopilot). ci is not offered: the tick needs the daemon's memoized alias pass. alias_autopilot.interval_minutes and max_per_tick set the cadence and the ceiling. |
A value outside a workload's allowed set falls back to its default and warns on stderr. The daemon reads the owner per tick, so handing a workload over is one commit: every host picks it up on its next sync, with nothing to restart and no window where both sides fire. A CI job whose workload it does not own exits 0 with skipped: owner=… in its log, since a daily red pipeline teaches people to ignore pipelines.
Ownership does not decide where a job runs — that is the runner tag, Set up a runner.
server¶
| Key | Type | Default | Meaning |
|---|---|---|---|
bind |
string | 127.0.0.1 |
Ignored — the listener always binds loopback, regardless of this value; the key documents intent. |
port |
int | 0 |
0 = OS-assigned ephemeral port (the chosen value is published to runtime/daemon.port). Pinnable here or via the boot script's -Port, which wins. |
read_only |
bool | false |
Boots the daemon in read-only mode — every mutating route 403s at the write gate. The boot script's -ReadOnly switch forces the mode regardless of this key; Set-NerthusMode flips it at runtime without editing the file. |
sync_max_age_min |
int | 60 |
Staleness bound (minutes) for the SyncStale write-gate check on a sync-enabled instance. |
sync_branch |
string | "" |
The branch the git sync converges and publishes; empty auto-resolves from origin/HEAD. |
gc_interval_min |
int | 60 |
Minutes between the idle collections that keep a long-lived containerized host off its cgroup cap; 0 disables them. |
reader_threads |
int | 0 |
Threads answering the reads the route table marks poolable. 0 serves every route on the accept loop. Host-local local/server.json and the boot script's -ReaderThreads both outrank this key (see Precedence). |
search_index |
string | "" |
Which full-text tiers this host may build an index for: lore, session, speech, external, all, or a comma-joined list. lore carries session with it — the two are one walk of one tree split by kind — so a config predating the split keeps both halves. Empty is none, and a host with none still serves the search routes — they answer 503 SearchIndexDisabled naming this key. Opt-in per tier because the two cost differently: on the development corpus the lore index holds 59 MB and the speech index 36 MB. An unrecognized tier name is ignored rather than fatal. Host-local local/server.json outranks this key; on a fleet host the container entrypoint writes that file from NERTHUS_SEARCH_INDEX. The read behaviour is owned by the search API reference. |
mention_index_interval_min |
int | 15 |
Minutes between the checks that this host's mention postings still describe its own transcript archive; a check that finds them stale rebuilds them in the background. 0 disables the check and leaves the rebuild to POST /logs/index/rebuild. |
event_journal |
bool | true |
Appends every durable event this host publishes to .nerthus/log/events.jsonl, which GET /events/journal pages by sequence. false leaves the SSE stream as the only event surface, and with it the bounded replay window — a reader that was away then has no way back to what it missed. |
selfheal_interval_ms |
int | 5000 |
Milliseconds the daemon may go without re-checking whether the lore moved behind its back. 0 checks before every request. |
selfheal_interval_ms bounds a stat pass over every session-bearing file — a thousand of them on
the production corpus. At 0 that pass runs ahead of every answer, which caps the loop however
many reader threads sit behind it; at the default an edit made outside the daemon is visible
within five seconds instead of on the very next request. Nothing the daemon writes waits for it:
every write rebuilds the model directly, and so does a converge.
reader_threads is worth raising on a host that serves the dashboard, together with the
interval. Measured on a 3282-session corpus with eight clients, four readers answer 87 requests a
second at a median of 8 ms against 70 and 39 ms single-threaded. What it buys is isolation rather
than throughput — reads stop queueing behind a settle run, a mass fetch, or a rebuild. Which
routes are poolable, and which a replica may answer at all, are owned by the
API reference.
The two sync_* keys tune the in-daemon git sync but can never enable it — enablement is host-local (local/sync.json or a bound boot parameter; see Precedence), so both keys are inert on every non-syncing clone. The sync subsystem itself is owned by Sync.
When the daemon is published behind a Cloudflare Tunnel, the container pins port to 8787 so the edge ingress has a stable loopback target. bind still stays loopback: cloudflared shares the daemon's network namespace and reaches 127.0.0.1:8787, so nothing binds a public interface (Publish the API).
corpora¶
| Key | Type | Default | Meaning |
|---|---|---|---|
corpora.mc |
string | "" |
The moderators repository's working-tree path — a second working tree this host reads but does not own. Host-local local/corpora.json outranks this key (see Precedence). |
corpora.dziel |
string | "" |
Repozytorium Dzieł's working-tree path — the works repository, holding external and player-authored works. It feeds the external search tier and the Dzieła root on Lore files, both behind external.read. |
corpora.adnotacje |
string | "" |
The labelled-sample store's working-tree path — repozytorium-adnotacji, the rows every tooling project deposits about the corpus. It is the whole input to GET /training/samples and to the two confirmation writes beside it, and therefore to the dashboard's Próbki and Rozstrzyganie screens (The sample and model screens). Empty is why those screens are blank on a fresh host: the route answers available: false, reason: NoStoreConfigured rather than erroring, so nothing on the surface says a path is missing. Setting it is one line. |
corpora.probki |
string | "" |
The finetuning sample store's working-tree path — repozytorium-probek, the second sample store: chat exchanges for training a generative model rather than labels over spans. It is the whole input to GET /training/finetune/samples, to the export beside it, and to the dashboard's Douczanie screen. Empty behaves as corpora.adnotacje does — available: false with a reason rather than an error — so a blank screen on a fresh host is a missing path rather than an empty store. Set it per host and never commit the value: it is an absolute path on the machine that holds the checkout, which is why it lives here and not in the repository the daemon reads. |
corpora.logi |
string | "" |
The transcript archive's working-tree path — repozytorium-transkryptow, the committed nerthus.logs/ archive (Logs). Set and existing, every archive read resolves there, every fetch writes there, and the host's sync tick commits and pushes that checkout (Sync). |
corpora.lang |
string | "" |
The annotation tree's working-tree path — repozytorium-lingwistyczne, the nerthus.lang/ sidecar tree (Annotation sidecars). No host writes it — one scheduled CI job regenerates the whole tree (The language pipeline) — so its sync tick runs fetch-only. |
Empty means the corpus is absent on this host, and absent is a supported state rather
than a boot failure: the roster reads as empty and every moderation surface says so, the
external tier holds nothing and says so — present and unreachable, rather than a host
that refuses to boot. Each path is configured rather than derived from the lore root
because each repository's merge rights are deliberately separate from the lore repo's,
which is the reason it is a repository of its own.
The two youngest keys carry a legacy fallback the older three do not: corpora.logi
and corpora.lang empty do not mean the corpus is gone — the daemon falls back to the
nerthus.logs/ and nerthus.lang/ inside the lore checkout, where both trees lived until
the August 2026 repository splits. An un-migrated host therefore keeps serving the archive
and the annotations it already has with zero configuration, and a post-split lore clone
simply has nothing at the legacy path, which is the same supported absence as everywhere
else. Unsetting either key is also the rollback for the corresponding split (Splitting
out the transcript archive,
Splitting out the annotation tree).
How many of the five a given host sets is a fleet's decision rather than a property of the
daemon. Measured 2026-08-23 on the dev fleet — torneg, karka-han and eder, all
three identical: local/corpora.json carries three of the five keys, mc, logi and
lang. The lore checkout is not among them and never is — it is /repo itself — so the
same fleet is three sibling corpora on this page's denominator and four served trees on
Fleet's. corpora.dziel and corpora.adnotacje are unset on every
host, so GET /training/samples answers available: false, reason: NoStoreConfigured there
and the external tier holds nothing. Both volumes exist and mount empty, which is a state
the fleet chose (fleet.settings.yml).
content_gate¶
| Key | Type | Default | Meaning |
|---|---|---|---|
content_gate.sniff_chars |
int | 400 |
How much of a fetched payload's head decides whether it is what was asked for. Enough to see a doctype or an opening element past whatever whitespace precedes it. The annotation pipeline sniffs the same window on the other side of the wall, and the two must not drift apart. |
content_gate.binary_share |
number | 0.2 |
The replacement-character share of that head above which the body is binary rather than text. A value at or below 0 or above 1 is ignored rather than honoured: either would turn the check into "always" or "never". |
The gate runs at the fetch, where the content type is in hand exactly once, and refuses a
payload that is not a transcript into a .failed marker instead of an archive slot
(Logs & audit). Both numbers are measured rather than chosen. Over all
1 174 raw copies in the development archive, one binary payload reads 43.9% replacement
characters, the worst real transcript reads 2.7% — a Windows-1250 paste decoded as UTF-8,
every Polish letter mojibake and every word still legible — and the next one after that
reads 0.01%. A gate at 20% sits in the middle of a sixteenfold gap; a tighter one is not
safer, because it deletes that session.
Both keys serve three callers, not one. The gate takes the caller's expectation as a parameter — a transcript, a page, or a binary artifact — so a works-repository fetch (Fetch a source) asks the same four checks a different question rather than carrying a second gate. The numbers are shared because the sniff window and the binary threshold do not depend on what was asked for.
scrape¶
| Key | Type | Default | Meaning |
|---|---|---|---|
scrape.pdf_confidence_floor |
number | 0.5 |
Below this a fetched PDF writes no sidecar. The measure is the share of pages that yielded a text layer, bounded by the share of character codes that mapped to Unicode. A value outside 0–1 is ignored. |
scrape.max_payload_bytes |
int | 20971520 |
The largest payload a fetch will keep. The works repository is committed content rather than a mirror, and every original stays in git until a withdrawal rewrites the history. |
Both belong to the works-repository fetch and to nothing else; a host that never runs one ignores them. The floor exists because a scanned PDF is a document with no readable text, and a sidecar made from it would be an empty work standing where a real one should be — so it is recorded and refused, with its original kept. The separation measured so far is total rather than marginal: a text PDF from the producer the corpus's own PDFs come from scores 1.0, an image-only page scores 0.0, and nothing between the two has been seen. 0.5 is the middle of that gap. There is deliberately no page equivalent. A web page extracted imperfectly still carries its text, so its number is written into the sidecar and never used to refuse it — the operator ruling is that quality problems are recorded, not repaired by guessing.
integrations.lang¶
| Key | Default | Meaning |
|---|---|---|
frames_config |
"" |
Path to Nerthus.Lang's frames-config.json — the file carrying the engage gate: the two recalls, frames-only against frames-plus-a-model, that decide whether the model layer is worth turning on for a surface. The daemon does not compute them; it reads the committed row and reports it. |
This is the key GET /model/evaluation refuses by name. Without a readable copy the answer is "no frames-config.json is readable on this host: set integrations.lang.frames_config, or drop a copy at <model root>/frames-config.json. Without it this daemon cannot say what the engage gate would compare" — a refusal that names a config key which, until now, appeared on no page of this site. Either arm satisfies it: the config key, or the file at the model root beside the artifacts (The model artifact).
The gate's own numbers carry their provenance inside the file — a measured block with its date, unit, method, population and gain — and Governance proposals explains how to read them. Nothing on this host recomputes them, so a stale copy reports a stale gate silently; the file's date is the only thing that says how old the answer is.
No logging block¶
There is no logging config block. The three JSONL stream names under .nerthus/log/ — operational, request, audit — are fixed in code, the streams are append-only with no rotation, and request bodies are never logged (see Logs). Earlier drafts declared level, retention_days, and request_body keys; they were dropped as unenforced promises — a future consumer reintroduces its key together with the code that reads it.
integrations.discord¶
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
false silences all Discord sends; the PU batch then skips its notifications. |
rate_per_minute |
30 |
Reserved — no throttle consumer yet. |
client_id |
"" |
The Discord OAuth application whose id_tokens this host accepts, and the audience each one is checked against. Empty leaves Discord sign-in off: POST /auth/discord answers 503 discord_auth_disabled. |
issuer |
https://discord.com |
The iss claim an accepted id_token must carry. |
jwks_url |
Discord keys URL | Where the Discord OIDC verification keys are fetched. |
jwks_cache_ttl_min |
1440 |
Minutes a fetched JWKS stays cached at cache/discord-jwks.json. 0 or less never expires the cache. |
guild_id |
"" |
The (non-secret) Discord server the bot posts into; committed here. A host-local guildId overrides it. |
api_base |
https://discord.com/api/v10 |
Discord API base for the channel resolver's GET /guilds/{id}/channels lookup and the message POST. |
channel_cache_ttl_min |
60 |
Minutes a resolved channel-name → id lookup is cached. |
The first four keys carry Discord sign-in, whose flow is owned by Permissions. client_id is the switch: Discord signs every registered application's tokens from one JWKS, so the audience is what binds a token to this deployment, and a host that has not set it accepts none. A kid the cached document does not carry drives exactly one refetch, so a rotation lands before the TTL does. A jwks key holding a key document short-circuits the fetch; only the test suite sets it.
Delivery detail lives in Logs. Per-person channels never live in config — they are @discord tags (kanał, kategoria[, cel]) on the ## Osoby blocks of the committed contributor store; the bot token is host-local (see Secrets), never in config.
The converge_announce sub-block is the other direction: not a message this host sends about a
person, but a message it sends about itself. Each time a sync tick fast-forwards one of the
repositories this host holds onto a commit it did not already have, the bot posts one plain-content
line to the tech channel naming the host, the repository, the short sha, and the people who wrote
the commits it took up. It covers the lore checkout and every corpus in corpora alike.
One message per host, not one per fleet. GitLab already says that main moved; what nothing
could say is that a particular host — with its own interval, its own network and its own volume —
is now serving those bytes. Six hosts converging is six lines, and a host that stays quiet while
its peers announce is the point of the design rather than a cost of it.
The author names and addresses are the commits' author fields (%an / %ae) read with
--no-merges. The lore repository lands every change as a merge commit, so the tip's author is
whoever pressed merge; a message built off the tip would name that person for every announce the
fleet ever makes.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
false stops the announce alone; integrations.discord.enabled false still stops it along with every other send. |
channel |
szary-monitoring |
The @discord channel name the announce is posted to — the same channel fleet:provision posts its notice to, so provisioning and convergence read as one story. |
category |
IT |
The channel's category, the second half of the committed name pair. |
max_authors |
5 |
How many distinct authors one message names before it counts the rest. |
Only the primary delivers today: the fleet agent renders the bot token for that role alone, so a
replica records a SKIP with reason no_token instead of posting.
Only a tick that fast-forwarded announces. A tick that pushed is a host reporting its own
write, a tick that found nothing has no news, and a sha already announced on this host is silent —
the last announced sha per repository is kept in runtime/announce/<corpus>.json.
The close_reminder sub-block tunes the reminder; who runs it is schedules instead.
| Key | Default | Meaning |
|---|---|---|
first_reminder_delay_hours |
0 |
How long a session stays open before its first nudge. 0 nudges on the next pass. The clock starts at the session's effective date (00:00 UTC), the one anchor a fleet daemon and an ephemeral CI clone both derive from the committed lore; a date that does not parse counts as eligible now, so a bad timestamp can never silence a reminder. |
interval_hours |
24 |
Cadence of the in-daemon tick. On a CI-owned reminder the schedule's cron is the cadence and this is unread. |
cooldown_hours |
48 |
Floor between nudges for one session on one channel, from the machine-local ledger. The CI pass runs with noDedup, so its cadence is the schedule's. |
The reminder is owned by the session model.
The dispatch sub-block tunes the ingest Worker's daemon-first path (Discord API). window_ms (default 5000) is the receipt-relative acceptance window and must stay 1500 ms under the Worker's DAEMON_DISPATCH_TIMEOUT_MS, so an accepted dispatch is one the edge still waits on; near_deadline_ms (3000) additionally bounds a report, which two enactments would turn into two merge requests; skew_alert_ms (2000) is the clock-skew warning threshold; max_attempts (3) bounds retries of one queued interaction; max_per_tick (1) and tick_budget_ms (2000) bound how long one drain may hold the single serve thread away from /health. An out-of-range value falls back to the default rather than refusing the boot.
integrations.logs¶
| Key | Default | Meaning |
|---|---|---|
hosts |
see shape above | Allow-list of hosts the game-log fetcher may reach, re-checked at every redirect hop. A committed list replaces the default whole. A Google Docs link needs googleusercontent.com here as well: its text export answers 307 to doc-XX-XX-docstext.googleusercontent.com, and without that host every Docs link fails at the walk. |
fetch_timeout_ms |
15000 |
Per-fetch timeout. |
failed_ttl_hours |
24 |
Base wait before a .failed marker is retried; 0 or less disables auto-retry. |
failed_max_attempts |
5 |
Identical failures in a row after which the URL stops being retried automatically. |
A .failed marker records the consecutive attempt count and the last error. Attempts one to three wait failed_ttl_hours; every attempt past the third doubles the wait, to a ceiling of eight times the TTL. On the default the curve runs 24, 24, 24, 48, 96, 192 hours and holds at 192. Once the same error has come back failed_max_attempts times running, the marker's state reads PermanentlyFailed and automatic retries stop for that URL. Only an explicit refresh (-RetryFailed or -Force on a fetch) picks it up again. A different error restarts the identical-failure run and the URL stays in the automatic rotation; the attempt count behind the wait keeps climbing, so the interval never drops back to the base TTL.
The fetch and parse pipeline is owned by Logs.
integrations.margonem¶
Endpoints and TTLs for the Margonem auth flow, whose mechanics are owned by Permissions.
| Key | Default | Meaning |
|---|---|---|
profile_url |
Margonem profile URL | Records the flow's external profile endpoint; the daemon never calls it. |
validate_url |
Margonem validate URL | Records the flow's external validation endpoint; the daemon never calls it. |
signing_key_url |
Margonem key URL | The public signing key the daemon fetches and caches. |
signing_key_cache_ttl_s |
86400 |
Cache lifetime of the fetched signing key. |
auth_timestamp_skew_s |
300 |
Payload freshness window for signed auth payloads. |
session_ttl_s |
14400 |
Minted session-token lifetime. |
settle_window_days |
3 |
Days from the 1st of the month during which the post-converge lint reads a rewrite of the robot's own files as the monthly settlement rather than a finding (Sync). Outside the default table: the lint holds the fallback. |
integrations.margonem.map_checkup¶
The CDN version sweep over Mapa @url values — Locations owns its contract, the schedules Sync and Set up pipelines.
| Key | Default | Meaning |
|---|---|---|
interval_hours |
24 |
Tick cadence. Whether the tick runs at all is schedules.map_checkup.owner; the explicit route always answers regardless. |
request_delay_ms |
100 |
Spacing between HEAD probes. The daemon probes over a pooled connection, so the CDN sustains far more than this; the value is deliberately conservative rather than necessary. |
timeout_ms |
10000 |
Bound on each probe. |
max_probe_steps |
5 |
Cap on how many versions past the current one a sweep walks. |
failure_budget |
20 |
Aborts the sweep after that many consecutive map errors (a dead CDN). |
batch_size |
300 |
Cap on a tick's map count. |
tick_budget_ms |
4000 |
Cap on a tick's wall-clock. |
retry_cooldown_ms |
30000 |
Wait before the single retry after a network error. |
hosts |
["micc.garmory-cdn.cloud"] |
Probe allow-list. |
Behavior notes: a 404 is the normal "no update" answer and never counts against the budget; scattered throttle hits only flag their maps. batch_size and tick_budget_ms both end a chunk, whichever is hit first. The budget is read between maps and before each probe step. A tick therefore holds the single dispatch thread for at most tick_budget_ms plus the one probe already in flight: its request_delay_ms pause and its timeout_ms ceiling. A retry cool-down on a tick is clamped to what the budget has left, and once nothing is left the network error stands and the map lands in failed for the next sweep to re-probe. The explicit route runs unbounded and takes the cool-down whole; it is sized to ride out the CDN's sliding rate-limit window. A Mapa @url on a host outside hosts is skipped, mirroring the log-fetch SSRF rule; an explicitly empty list probes nothing (only a missing key falls back to the default host). Probes never follow redirects: a redirecting CDN front reads as errors that spend the budget, never as freshly published versions.
sessions¶
Staged sessions and the landing transaction (session model). Only staged appears in the default table; every key beneath it resolves in its own consumer, so a sessions block holding just staged is complete rather than truncated.
| Key | Default | Meaning |
|---|---|---|
staged |
false |
Enables the staged-session plane: writes accumulate as host-local drafts and closing lands them atomically. A per-host pilot flag; the boot script's -Staged switch forces it on for one boot, and a -ReadOnly boot never stages. |
close.max_attempts |
5 |
Compare-and-swap attempts a landing transaction makes before it gives up. A value under 1 reads as 1. |
gate.taxonomy |
see below | Per-finding-kind override of the semantic gate's disposition. |
staging_nag.enabled |
false |
Runs the staging-age nag as an in-daemon tick on the primary. An absent staging_nag block leaves the nag off. |
staging_nag.interval_hours |
24 |
Cadence of that tick. |
staging_nag.warn_days |
7 |
Draft age at which the nag posts to the Rada channel. |
staging_nag.escalate_days |
25 |
Draft age at which the nag escalates its wording. |
staging_nag.cooldown_hours |
48 |
Floor between nags for one draft at one tier, from the machine-local ledger. |
gate.taxonomy is an object mapping a finding kind to refuse (a hard 4xx) or escalate (an auto-merge-request to the Rada), so the Rada tunes close-time noise one kind at a time. Any other value, and any kind not listed here, resolves to escalate.
| Finding kind | Default | Raised by |
|---|---|---|
name-ambiguous |
refuse |
a fan-out target name resolving to several entities |
name-miss |
escalate |
a fan-out target name resolving to none |
chimera |
refuse |
a ### Name heading duplicated within one declared type |
choke-file |
refuse |
draft content naming Gracze.md or nerthus.pu-sessions.md |
header-changed |
refuse |
an already-echoed session whose header line changed on the default branch |
dangling-claim |
escalate |
a @forma_sesyjna claim with no matching mention |
claim-conflict |
escalate |
two claims contending for one subject |
claim-on-deleted |
escalate |
a claim naming a soft-deleted entity |
integrity |
escalate |
a Critical or High session-integrity finding |
materialize-error |
escalate |
nothing yet — the map carries the kind, no check raises it |
The gate's checks and what a refuse and an escalate each do to the close are owned by the session model, which also owns the nag the staging_nag keys pace.
fleet.auto_failover¶
Both the health worker's automatic flip and a daemon's mesh-requested takeover read these two thresholds. Fleet owns the flip, Run a failover the procedure.
| Key | Default | Meaning |
|---|---|---|
after_sweeps |
3 |
Consecutive dark sweeps of the primary an unforced takeover requires. |
cooldown_hours |
24 |
How long a proposed or merged takeover suppresses the next one. |
The daemon resolves both at boot, so a retune lands on the next restart. The environment is read first: NERTHUS_AUTO_FAILOVER_AFTER_SWEEPS and NERTHUS_AUTO_FAILOVER_COOLDOWN_HOURS, which the fleet agent renders from the host descriptor. An unset or empty variable falls through to the committed config.json, and an absent key there to the built-in default. Whichever tier supplies the value, a non-numeric or non-positive one resolves to the default — a truncated config cannot disarm the guard by omission.
Tunable PU constants (pu block)¶
Rada policy for the monthly PU batch and election eligibility; the full algorithm is owned by PU model.
| Key | Default | Meaning |
|---|---|---|
monthly_base |
1.0 |
Universal +1/month every active character receives on top of session grants. |
monthly_cap |
5.0 |
Maximum PU credited to SUMA in one month; the remainder spills to NADMIAR. |
election_window_months |
6 |
Rolling window for election eligibility. |
election_threshold |
3.0 |
Windowed score below which a Gracz is ineligible. |
new_character.divisor / .bonus / .floor |
2.0 / 20.0 / 20.0 |
The new-Postać STARTOWE baseline formula — see PU model. |
The normalization worklist (normalization block)¶
| Key | Default | Meaning |
|---|---|---|
narrator_near_form_score |
0.85 |
How close an unresolvable narrator signature must come to a block before the worklist proposes them as the same person. |
The score is the resolver's fuzzy reading, 1.0 - edit_distance / query_length, and the key is a
floor on it: below the value the bliska-forma sieve stays quiet and the signature gets the plain
"reaches no block" row instead (the worklist). It is a judgement about a corpus
rather than a constant, which is why it is here — at 0.85 on the campaign corpus the sieve keeps
five one-keystroke misspellings and refuses Rote against Velrose, which are two different
people. Lowering it to 0.80 admits a sixth that nobody has ruled on; raising it to 0.90 removes
the sieve's whole population.
The default lives in the module's built-in table and this block overrides it, exactly as the pu
block does. The measurement behind the number — what each candidate scored, and what moving the
floor would cost — is recorded in the project directory that set it, which nothing reads at
runtime.
Rada data tables (.nerthus/data-tables/)¶
Three committed lore tables the Rada edits directly: currency.json (denominations), roles.json (role → capability bundles), and towns.json (reputation settlements). The seeded shapes, row semantics, and the shared lifecycle rules (read on use, seed only when absent, fallback to baked-in defaults) are in Data tables.
The public schema route serves the effective contents — statuses, tags, entity types, denominations, towns, role names, and the capability catalog derived from the route table and role bundles. The PU constants and the full role bundles stay off that route.
Precedence¶
Three tiers, highest wins — deliberately flat:
- Request parameter — a value passed on the call overrides the file for that operation:
Set-NerthusMode -ReadOnlyflips the running daemon without touchingserver.read_only; the daemon boot script's-Portbeatsserver.port, its-Stagedbeatssessions.staged, and its-ReaderThreadsbeats every lower tier for one boot. Runtime overrides are never written back toconfig.json. - Committed config —
config.jsonanddata-tables/as checked in. - Daemon defaults — the built-in values shown above, used for any omitted key. A fresh scaffold writes
config.jsononly when absent, carrying the seed block.
schedules takes the normal three tiers: it is committed, unlike git-sync enablement, because both the fleet daemon and an ephemeral CI clone have to read the same answer out of the same artefact.
server.reader_threads takes a fourth source between the first two tiers, because a thread count describes the machine rather than the lore: a bound -ReaderThreads wins, then the gitignored local/server.json, then the committed key, then 0. The committed key still participates, unlike sync enablement — a thread count every clone shares is a poor default, not a broken deployment.
fleet.auto_failover reads the environment above the committed file: the two NERTHUS_AUTO_FAILOVER_* variables the fleet agent renders per host (above). There is no user-profile tier, and host-specific secrets stay outside the committed files, resolved by reference, not by precedence.
Note
Git-sync enablement never enters the three tiers. It resolves from host-local sources only — a bound boot parameter (-SyncIntervalMinutes, -SyncPublishMode), else .nerthus/local/sync.json, else disabled; the committed config.json cannot participate. A bound -SyncIntervalMinutes 0 force-disables sync for that boot even when sync.json enables it — the operator's one-boot escape hatch. The two committed sync_* tunables keep the normal order beneath those sources (Sync).
Secrets, runtime & host-local files¶
Secrets never live in tracked files. Discord delivery keeps its secret out of git: the bot resolves channels by name (@discord on the committed contributor store; Logs owns resolution and delivery), so the only committed Discord data is channel names — lore, not secrets. The bot token is a new out-of-band secret a per-channel webhook never needed, and it is broader — one token can post to every channel the bot can see, where a webhook reached exactly one channel. That is a deliberate scope tradeoff: a single host-local secret buys operator simplicity and a read-privacy win — the bot holds only View Channel + Send Messages (no Read Message History, no Message Content intent), so players' channels are written, never read. The token lives host-local (.nerthus/local/discord.json), or in the CI vars DISCORD_BOT_TOKEN (masked) + DISCORD_GUILD_ID, never in config.json. Everything else sits in the gitignored .nerthus/runtime/ (machine-managed) and .nerthus/local/ (operator-managed):
| File | Notes |
|---|---|
runtime/daemon.token |
The machine token — mode 0600, full capability, minted at boot. |
runtime/daemon.port, runtime/daemon.pid |
Discovery files the client reads to find the daemon. |
runtime/tokens/ |
Session and named token records, stored under the token's SHA-256 id — a raw bearer value is never written to disk (see Permissions). |
runtime/sync.json |
The sync tick stamp: observability plus the cross-restart seed for the publish-retry state; the write gate never reads it (Sync). |
local/sync.json |
Host-local config: git-sync enablement, shape below. |
local/server.json |
Host-local config, not a second copy of config.json: the boot script reads readerThreads and searchIndex from it, above the committed server.reader_threads and server.search_index. On a fleet host the container entrypoint writes it from NERTHUS_READER_THREADS and NERTHUS_SEARCH_INDEX, and writes no file when both are unset. |
local/corpora.json |
Host-local config: the corpora working-tree locations on this machine, e.g. { "mc": <path>, "logi": <path>, "lang": <path> }. Any of the five corpora.* keys may appear, and only that known set merges — an undeclared key in the file reaches nothing. Every boot path reads it above the committed keys — a filesystem path belongs to the machine, never the committed config. The container entrypoint renders it as one accumulated document covering exactly the corpora whose clone succeeded (mc, logi, lang from their NERTHUS_*_URL variables), so a configured-but-empty tree is never reported and one failed clone does not erase another corpus's key. |
local/gitlab.json |
Operator-managed GitLab proxy credential — { "token": … }, the one the /gitlab/* routes read CI with (GitLab CI proxy). Absent here, NERTHUS_GITLAB_TOKEN supplies it (a bound env var wins). Absent entirely, those routes answer GitLabNoCredential and every other route is unaffected. It is recorded in the sejf/ inventory with a rotate_by; the daemon reads it once, at boot. |
local/discord.json |
Operator-managed Discord bot credential — { "botToken": …, "guildId": … }. The botToken is the delivery secret; absent here, the CI env vars DISCORD_BOT_TOKEN + DISCORD_GUILD_ID supply it (a bound env var wins). |
local/ |
Operator-managed host-local files. The webhook extract local/secrets.json is retired — the daemon never reads or writes it; a leftover copy is inert and safe to delete. |
local/sync.json is not a secret but is deliberately un-committed. Its shape: { "intervalMinutes": 5, "publishMode": "push" } — every boot path reads it, the client auto-spawn included, and the daemon is a syncing instance exactly when the resolved interval is at least 1. publishMode is push (the primary writes onto the default branch), mr (the primary publishes reviewed merge requests instead), or none (a converge-only replica). On a fleet host the container entrypoint renders this file rather than an operator: the role settles none, and NERTHUS_SYNC_PUBLISH_MODE chooses between push and mr for a primary. Enablement can never travel through the committed config.json: a committed interval would turn every clone of the repo — every home and CI daemon — into a syncing instance whose writes freeze behind failing fetches. Behavior is owned by Sync; the setup procedure by Bootstrap a fleet host.
The Margonem signing key cached at cache/signing-key.pem is a public verification key, not a secret — it lives under gitignored cache/ only because it is refetchable machine state. A missing channel (@discord: BRAK, or no tag) disables the affected delivery with a SKIP recorded in the delivery log, never a daemon crash.
Container environment (fleet hosts)¶
A fleet host runs the daemon in a container, and the entrypoint renders the host-local config files — local/sync.json, local/server.json, and local/corpora.json — from environment variables rather than from a hand-edited config.json. The compose stack sets the variables below from the host's rendered descriptor (Fleet, Bootstrap a fleet host):
| Variable | Default | Meaning |
|---|---|---|
NERTHUS_ROLE |
replica |
primary boots read-write and keeps Discord; replica boots -ReadOnly -NoDiscord with publishMode: none. Any other value throws. It is also the role this host claims on GET /fleet/status. |
NERTHUS_SYNC_PUBLISH_MODE |
mr on a primary |
push, mr or none, written into local/sync.json; any other value throws. A replica is forced to none whatever it is told. |
NERTHUS_SYNC_INTERVAL_MIN |
5 |
intervalMinutes in local/sync.json. A non-integer or a value under 1 throws rather than falling back. |
NERTHUS_READER_THREADS |
unset | readerThreads in local/server.json. Unset writes no file, leaving the committed key to decide. A non-integer or a negative value throws. |
NERTHUS_SEARCH_INDEX |
unset | searchIndex in local/server.json, which outranks server.search_index. Takes lore, session, speech, external, all or off, comma-joined, and the booleans (true, false, 1, 0, none) still parse so a host that already said true keeps every tier; anything else throws, naming the value. Unset writes no key, and with NERTHUS_READER_THREADS also unset writes no file at all. This is how a containerized host is given a search index — on the fleet, setting only the committed key leaves the host answering 503 SearchIndexDisabled. |
NERTHUS_LORE_URL |
— | The lore clone URL. Read only when the repo root is empty, and required then. |
NERTHUS_MC_URL |
unset | The moderation corpus clone URL. Absent is supported and means this host holds no moderation tree at all. A corpus that cannot be cloned never stops the boot: the entrypoint warns, and the host serves an empty moderator roster and reads regulations from the lore tree. |
NERTHUS_LOGI_URL |
unset | The transcript-archive clone URL (corpora.logi). Absent is supported and means the host falls back to the nerthus.logs/ in its lore checkout — an un-migrated host keeps serving the archive it has, and a fetch on it writes there. Unlike the moderation corpus this host pushes here: every fetch writes the archive and the sync tick publishes it, so the deploy key needs write access on this repository or transcripts pile up locally. A failed clone warns and degrades to the fallback. |
NERTHUS_DZIEL_URL |
unset | The works repository's clone URL (corpora.dziel). Absent is supported and there is no fallback copy anywhere, unlike the two youngest keys: the external search tier holds nothing and says so, which is a different answer from a tier that is broken. Read-only — nothing on a host writes works — so the deploy key needs read alone and the tick runs fetch-only. A failed clone warns and the host serves an empty tier. |
NERTHUS_ADNOT_URL |
unset | The labelled-sample store's clone URL (corpora.adnotacje). Absent is supported and means the host holds no samples: GET /training/samples answers available: false, reason: NoStoreConfigured. Read-only on the annotation tree's terms — the rows are deposited elsewhere and a daemon only reads them — so read access and a fetch-only tick. |
NERTHUS_LINGW_URL |
unset | The annotation-tree clone URL (corpora.lang). Absent is supported: the host falls back to the nerthus.lang/ in its lore checkout, and serves no lemma layer if that checkout has none — which every consumer degrades to. No host writes this tree (one CI job does), so the key needs read only and the tick runs fetch-only. |
NERTHUS_GIT_NAME / NERTHUS_GIT_EMAIL |
— | The commit identity written into the checkout's own git config on every boot. |
NERTHUS_IMAGE_DIGEST |
"" |
The pinned image ref from versions.lock, reported verbatim on GET /health and GET /fleet/status, and the image a takeover proposal states it expects. |
NERTHUS_TOWN |
"" |
This host's town: the name on GET /fleet/status, and the roster entry the peer watch skips as itself. |
NERTHUS_FLEET_PEERS |
"" |
The roster as comma-joined town=hostname=role=takeover quadruples (the fourth field optional, defaulting to manual). A malformed entry is skipped with a warning; an empty value idles the watch. |
NERTHUS_PEER_WATCH_INTERVAL_MIN |
5 |
Minutes between peer probes. A value that is not an integer in 1–60 falls back to 5 and logs one boot line naming what it rejected. |
NERTHUS_AUTO_FAILOVER_AFTER_SWEEPS / NERTHUS_AUTO_FAILOVER_COOLDOWN_HOURS |
— | The failover thresholds from the host descriptor; empty falls through to fleet.auto_failover. |
Four more reach the daemon without the descriptor supplying them. Each falls back to a working value, which is why a rendered .env names none of them:
| Variable | Default | Meaning |
|---|---|---|
NERTHUS_REPO |
/repo |
The repo root the entrypoint clones into and the daemon serves. |
NERTHUS_PORT |
8787 |
The loopback port, passed to the daemon as -Port. The compose stack publishes no port; cloudflared shares the namespace and reaches it over loopback. |
NERTHUS_LORE_KEY |
/run/keys/lore.key |
The deploy-key path inside the container, which compose bind-mounts read-only from the host. A configured path that is not a file throws on an empty volume and warns on a populated one, where the host serves what it has and every fetch fails. |
NERTHUS_MC_REPO |
/mc |
Where the moderation corpus clone lands when NERTHUS_MC_URL is set. |
NERTHUS_LOGI_REPO |
/logi |
Where the transcript-archive clone lands when NERTHUS_LOGI_URL is set. |
NERTHUS_LINGW_REPO |
/lingw |
Where the annotation-tree clone lands when NERTHUS_LINGW_URL is set. |
NERTHUS_HOST_NAME |
the machine hostname | The slug in this host's committed echo filenames. Compose sets hostname: <town> instead of this variable, which reaches the same value. |
Five further variables carry the fleet mesh's credential and its actuation coordinates, and nothing in the fleet provisioning renders any of them: neither the converge agent's .env nor the compose environment block names one, so a host holds them only where somebody set them on that host by hand.
| Variable | Default | Meaning |
|---|---|---|
NERTHUS_FLEET_PEER_TOKEN |
— | Its presence at boot writes a named token record fleet-peer carrying fleet.read, fleet.announce and derive.read. Absent, no peer can read this host's cached observations or copy a derived file from it. |
NERTHUS_GL_FLEET_TOKEN |
— | The GitLab token the takeover actuation pushes its role-flip merge request with. |
NERTHUS_FLEET_PROJECT_PATH |
— | The consumer project that merge request targets, e.g. margonem/nerthus/it/nerthus.core.infra.dev. |
NERTHUS_FLEET_API_BASE |
https://gitlab.com/api/v4 |
The GitLab API base the actuation calls. |
NERTHUS_FLEET_DEFAULT_BRANCH |
main |
The branch that merge request targets. |
POST /fleet/takeover answers 503 unless NERTHUS_GL_FLEET_TOKEN and NERTHUS_FLEET_PROJECT_PATH are both set. A fleet missing the credential degrades visibly rather than proposing flips it cannot land. The peer token's own set stops at reading and announcing: requesting a takeover needs fleet.takeover, which that record deliberately omits (Capabilities).
Beyond these, the daemon reads only DISCORD_BOT_TOKEN, DISCORD_GUILD_ID and NERTHUS_GITLAB_TOKEN, and the compose stack sets none of them. A fleet primary takes its token from local/discord.json inside the lore volume instead; a replica boots -NoDiscord and needs none.
The container relies on .NET's default in-container heap limit — 75% of the cgroup cap — and the idle tick runs a periodic garbage collection because pwsh on Linux does not return idle memory on its own; restart: unless-stopped is the backstop. Do not set DOTNET_GCHeapHardLimitPercent: its value is parsed as hexadecimal, so 75 would not mean 75 percent.
Test-runner gates¶
The comprehensive and live-drill suites are opt-in through environment variables, not config. They are test-runner gates, never product runtime config, and a CI run leaves them unset so the suites no-op. The suite map, and the one other env-gated suite — the adoption rehearsal behind NERTHUS_REHEARSAL_REPO, with the rule never to point it at a checkout you care about — are in Run and write tests. The gates below are documented here:
| Variable | Enables |
|---|---|
NERTHUS_CONTAINER_BURNIN |
The container burn-in against a locally built image. |
NERTHUS_WSL2_BURNIN |
The WSL2 continuity drill, which runs in two phases across a reboot. |
NERTHUS_SIGTERM_E2E |
The SIGTERM graceful-stop drill. It also needs a platform that can deliver the signal, so setting it alone does not force the suite to run. |
Each takes knobs beside its gate. The burn-in reads NERTHUS_BURNIN_IMAGE (default nerthus-core:burnin), NERTHUS_BURNIN_ENTITIES (4000) and NERTHUS_BURNIN_ROUNDS (25); the WSL2 drill reads NERTHUS_WSL2_PHASE (arm, then verify), NERTHUS_WSL2_HEALTH_URL and NERTHUS_WSL2_STATE for the snapshot it carries between phases.
Two more shape what the burn-in measures rather than whether it runs. NERTHUS_BURNIN_ARCHIVE points at a throwaway lore checkout used as the repo; without it the fixture carries no nerthus.entities.md, so the memory gates report inconclusive rather than passing on two entities. NERTHUS_BURNIN_SEARCH (default off) is the tier the burned-in host builds an index for, and NERTHUS_BURNIN_READERS (2) its reader-thread count.
The two knobs that move the burn-in's own pass condition¶
NERTHUS_BURNIN_MEMORY_MB sets the container limit the peak is measured against, and NERTHUS_BURNIN_HEADROOM sets the ratio it must stay under — the denominator and the threshold of the gate's own arithmetic. Raising either buys a pass. The peak grows more slowly than the limit does, so the ratio falls at every step and passes for good somewhere above 3 GiB while absolute demand keeps climbing; moving the threshold does the same thing at the committed limit, where nothing else in the output would say so. Only a run at the committed limit and the committed threshold is evidence about the fleet.
So a run that moves either must set NERTHUS_BURNIN_EXPLORATORY=1, or the suite throws and names the knob that was moved. With the acknowledgement set, the two memory gates report inconclusive with the knob named and the measurement card still prints — the number is the point of such a run. That distinction has to be explicit because Pester reports Result=Passed and exit 0 for a run whose every gate was inconclusive, so to CI, and to anything typing &&, an exploration and a licensing run are otherwise the same status.
NERTHUS_EVAL_REPO, NERTHUS_EVAL_CORPUS and NERTHUS_EVAL_REQUIRED gate the retrieval-eval suite the same way: the first two name the corpus, and the third turns a missing one from a skip into a failure.
schema.json pointer¶
.nerthus/schema.json ({ "version": "0.11.0" }) is a pointer-only file: the single index-format version the on-disk data satisfies. It is a separate file from config.json and unrelated to it. Two writers touch it, and the write gate reads it; the lifecycle is owned by Adoption.
Every boot scaffolds the file at { "version": "0.0.0" } when it is absent, so an unadopted clone holds a file reading 0.0.0 rather than no file at all. The one-pass import then stamps 0.11.0 over it. A SchemaTooOld refusal on a clone nobody has imported is therefore the scaffold's value, not a missing pointer.
Cmdlet surface¶
Configuration is read-mostly; there is no "set arbitrary config" route — the Rada edits files. The runtime-facing cmdlets are Start-Nerthus, Stop-Nerthus, Get-NerthusStatus, Get-NerthusSchemaVersion, Set-NerthusMode, and Initialize-NerthusRepo; routes, parameters, envelopes, and capabilities are in API. config.json is read once at daemon boot — apply an edit with Stop-Nerthus + respawn. The data-tables/*.json files are read on each use, so a table edit (a new denomination, a rebound role) takes effect on the next request with no restart.
See also¶
- Architecture — the
.nerthus/layout, data-owner contract. - API — the cmdlet ⇄ route mapping, envelopes, capabilities, the write gate.
- Data tables — the seeded shapes of
currency.json,roles.json,towns.json. - Capabilities — the capability catalog the role bundles grant.
- PU model — how the tunable PU constants are applied.
- Currency model — the denomination table in use.
- Permissions — roles, capabilities, tokens.
- Logs model — log streams, game-log fetch, Discord delivery.
- Adoption — the
schema.jsonlifecycle and the one-pass import. - Sync — the git-sync subsystem the
sync_*keys andlocal/sync.jsonconfigure. - Fleet — the container environment and the roster the host env vars come from.