Chat moderation¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
The moderation ledger, its evidence blobs, and the two ways an archive leaves the store. Every
record body is ciphertext addressed to the moderacja keyring; the daemon is not a
member of it and holds no key, so nothing on this page decrypts anything.
A capability decides which bytes the daemon hands over. Keyring membership decides who can read
them, and nothing in the first can grant the second. An admin.all principal fetches every
moderation record in the store and sees counts, days, size buckets and signatures — the same thing
a host operator sees. That is not a gap to be closed by giving the daemon a key: that one change
would hand the content to every host operator, every backup and every future IT member at once.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /moderation/records |
— | moderation.read |
— |
| GET | /moderation/records/{id} |
— | moderation.read |
— |
| POST | /moderation/records |
— | moderation.write |
✓ |
| DELETE | /moderation/records/{id}/wraps |
— | moderation.admin |
✓ |
| GET | /moderation/evidence/{sha} |
— | moderation.read |
— |
| POST | /moderation/exports |
— | moderation.export |
✓ |
| GET | /moderation/exports |
— | moderation.export |
— |
| GET | /moderation/disclosures |
— | moderation.read |
— |
| POST | /moderation/disclosures |
— | moderation.read |
✓ |
| GET | /moderation/transparency |
— | moderation.read |
— |
| GET | /moderation/transparency/root |
— | — | — |
Every row here carried a cmdlet name, and not one of them was a cmdlet. Eight named nothing at all — Get-NerthusModerationRecord, New-NerthusModerationRecord, Get-NerthusModerationEvidence, New-NerthusModerationExport, Get-NerthusModerationExport, Get-NerthusModerationDisclosure, Get-NerthusModerationTransparency and Get-NerthusModerationTransparencyRoot are defined in no .ps1 in Nerthus.Core and exported by no manifest. The other two, Remove-NerthusModerationBodyWraps and Add-NerthusModerationDisclosure, are defined — as daemon service functions, which is not the same thing: this column is the cmdlet ⇄ route mapping, and a function nobody can invoke from a shell does not belong in it. The cells are empty now, following the regulations page's precedent for a browser-only family: this corpus is read from the dashboard, and an empty cell is a true statement where a name was a false one. ci/assert-cmdlet-names.py in this repository fails the build if one comes back.
Cap is the required capability (— = public, no token); Write (✓) marks routes that pass
the write gate. Paths are relative to /v1/api; the cross-cutting contract is on the
API reference index.
The record store¶
The store — records, evidence blobs and the disclosure log — lives at .nerthus/moderation in
the moderators tree (corpora.mc), beside the regulations a record cites and never behind a
narrator's merge button. The lore path remains only on a host with no moderators corpus
configured at all: unlike the regulation corpus's root, the store root does not check the
directory exists before returning it, so a fresh corpus's first record lands in the right tree.
GET /moderation/records is the cleartext index and never a body. ?subject=, ?from=,
?to= and ?epoch= filter over cleartext columns, which is what makes them answerable at all.
There is deliberately no recidivism filter: computing one needs kind and
counts_toward_recidivism, both inside the ciphertext. A client fetches a subject's records,
decrypts them, and counts.
GET /moderation/records/{id} hands over one record as stored, ciphertext and all.
POST /moderation/records verifies the two things the daemon can verify without reading
anything: the signature validates against a published signing key of the named keyring, and the
declared epoch exists. Everything else — that a kill was signed by an SMC, that the reason code is
in the taxonomy — is a client-side rule over data the daemon cannot see.
| Refusal | Status | Raised when |
|---|---|---|
MalformedRecord |
400 |
The record is missing a required column or carries an unknown one. |
RecordRefused |
422 |
The body is well formed and the store refuses it — unknown keyring, an epoch that does not exist, a signature that does not verify. |
NoVerifier |
503 |
ssh-keygen is absent, so this host cannot check a record and must not accept one unchecked. |
NoVerifier is a 503 and not a 4xx because the request is fine and will succeed against a
host that has the verifier. A 4xx would send somebody to look at the moderator's key.
DELETE /moderation/records/{id}/wraps deletes the body wraps and nothing else. What it
destroys is readability, permanently and for everybody including the author; what it leaves is the
ciphertext, the signature, the tally wraps and the history — so the record still verifies, the
recydywa count still answers, and the shred is a commit somebody can point at. The tally survives
on purpose: the recydywa count is cumulative and lifetime, and may be cleared only by a
Namiestnik's decision, not by a retention clock.
Evidence¶
GET /moderation/evidence/{sha} returns one evidence blob by its blinded id, which is
HMAC-SHA256(K_tag, sha256(plaintext)). Only a keyring member can compute the address of a
candidate image; a plaintext digest here would let anyone crawl a screenshot host and test
membership in the store.
The blob is age ciphertext and travels base64 in a JSON body ({ sha, encoding, bytes }),
not as application/octet-stream. That is a property of the dispatcher rather than of the design:
handlers return objects that are serialized to JSON, and there is no byte arm. A caller decodes
before decrypting.
There is deliberately no upload route. The path 02-api.md describes is a moderator's browser
posting a blob it encrypted first, and no client does that; a route with no producer sits in the
table, satisfies every guard, and is never called. The CLI path (Save-NerthusEvidence) needs no
route. The row lands when the client that fills it exists.
Export, disclosure, transparency¶
One capability, two outputs, and the key decides which. Whoever passes the gate on
POST /moderation/exports gets the ciphertext bundle — every selected record as stored,
complete, provably untruncated against the transparency head, and unreadable. A Zarządca holds
admin.all and no key, so that is what they get, and it is the right answer to a backup or a
handover. An inspection needs plaintext, which an SMC produces client-side from records they
can already read and hands over out of band. No plaintext crosses the daemon on either path.
moderation.export is deliberately not folded into moderation.read: an MC needs read every day
to do the job, and an export is a single request that removes the entire corpus at once. Different
risk, different capability. It is currently in no role bundle — admin.all reaches it, so a
Namiestnik or Koordynator can take one today; a Zarządca gets a named token with an expiry rather
than a bundle bound to a @rola line, because the holder is a job at another organisation and
lapsing should be the default.
Three refusals, and none of them writes a row: an unknown or absent scope (there is no
default — an export that omitted its scope and got everything is the mistake the log exists to
prevent), a missing reason, and the 24-hour cooldown on scope: all, which answers
429 ExportCooldown while everything else answers 400 ExportRefused. A cooldown says try
later and a missing reason says supply one; one status for both would make a rate limit look
like a malformed request. The cooldown is read off the log, not a counter, so it survives a
restart and the thing that blocked an export is a row a reader can see.
The bundle is collected once, at GET /keys/claim/{sha} (Keyrings). Evidence blobs
are named in its manifest and not inlined: they are already committed ciphertext that every
host carries, and copying megabytes into a claim entry that dies in an hour buys nothing.
The daemon does not sign the export manifest, and it must not. A signature it produced would be made by the host, and the host's operator is IT — the party a disclosure manifest exists to constrain.
manifest_sigships EMPTY for a keyring member to fill with their own key, exactly as a record is signed. An unsigned manifest still fixes the record list and the transparency head at a moment; a manifest signed by the daemon would prove that IT agrees with IT.
GET /moderation/disclosures is moderation.read and not moderation.export, deliberately:
the people who should see that the archive was taken out are the ones who work in it, not only the
ones entitled to take it. A log visible exclusively to its own subjects is not a log.
POST /moderation/disclosures records a plaintext disclosure an SMC produced client-side — who
requested, who produced, what scope, when, and a hash of what was handed over.
GET /moderation/exports is that same log filtered to the ciphertext kind, not a second store.
GET /moderation/transparency/root returns the folded head and the record count and nothing else,
and it is the only public route in this surface: anyone should be able to detect that the store
was rolled back or truncated. The full chain stays behind moderation.read, because the id list
leaks the record count and timing to the world rather than only to IT.
The head folds the sorted record ids together with the sha256 of each record's stored bytes. Sorted, so two hosts on one commit agree — a chain built in walk order differs between filesystems and the difference reads as tampering. Over bytes, so rewriting a record moves the head: a head folding only ids catches truncation and not tampering, and tampering is the act a store's own operator can perform.