Forum
The forum is the one part of Eternaltwin where users write to each other directly, so it is also the one part that needs a moderation team. This page describes who may do what, and what the server records about it. None of it can be deduced from the schema alone.
Roles
Authority over a section is a total order. Every check in the code is a comparison against a threshold, so a level grants everything the levels below it grant.
| Level | Where it comes from | Scope |
|---|---|---|
Administrator | users.is_administrator | the whole site |
GlobalModerator | a row in forum_role_grants with no section | every section |
Moderator | a row in forum_role_grants with a section | one section |
| user | being signed in | — |
Authority is then capped by the credentials the request carries. A browser session has the full
authority of the account. An OAuth access token has the smaller of that and its own scopes:
without forum:write it may not write at all, with forum:write it writes as an ordinary member
and holds none of the account's roles, and only with forum:moderate do the roles apply. A token
never carries the administrator flag: that flag lives on the user row and no scope grants it, so an
administrator signing into a game does not bring their administrator powers in with them. The two
refusals are reported apart — a missing scope is answered by asking the user for a wider
authorization, a missing role cannot be answered by the client at all. See
The forum from an application.
A GlobalModerator reports Moderator alongside itself in the self block. That is deliberate:
already-deployed clients test for Moderator, and a global moderator that did not also say so
would silently lose every button.
Grants are temporal. Revoking a role closes its period instead of deleting the row, so "who
moderated this section, and when" stays answerable. Only an administrator may grant or revoke —
a GlobalModerator moderates everywhere but does not recruit, which are deliberately different
powers.
ForumRole — the value that travels on the wire, in ForumSectionMeta.self.roles — lists the
roles the current user holds in the section being read. An administrator who also holds a grant on
the section reports both.
Only an administrator may grant or revoke a moderator role. A moderator may revoke their own: stepping down needs no permission.
What the client is told
ForumSectionMeta carries a self block listing roles, and ForumThread carries one listing
decisions:
"self": {
"can_post": true, "can_lock": false, "can_pin": false,
"can_move": false, "can_delete": false, "can_report": false
}
These are computed by the server from the same predicates the write paths enforce, in
ForumPermissions (crates/services/src/forum/permissions.rs). A client should draw a button if
and only if the matching flag is set, and must not re-derive the rules: a second implementation of
the hierarchy drifts from the first within a release, and the drift is invisible until someone is
either handed a button that 403s or denied one they should have.
can_report is declared but always false — reports do not exist yet. It is on the wire already so
that turning them on does not change the shape of the response again.
Thread moderation
| Action | Who | Effect |
|---|---|---|
| pin / unpin | moderator of the section | the thread sorts to the top of its section |
| lock / unlock | moderator of the section | only moderators may reply |
| rename | moderator of the section | |
| move | moderator of both sections | the thread changes section; the previous one is kept in moved_from_forum_section_id |
| delete / restore | moderator of the section | see below |
Moving requires rights on both ends on purpose. A moderator who could push a thread into a section they do not moderate would be handing their colleagues a problem they never agreed to take; one who could pull a thread out of a section they do not moderate would be taking content away from its team.
Locking is not deletion: a moderator can still post in a locked thread, which is what makes it possible to explain the lock in the thread itself.
Deletion is reversible, and looks like absence
Deleting a thread sets deleted_at and deleted_by. The posts are untouched. The thread
disappears from its section listing, and reading it directly returns 404, not 403 — telling a
stranger that a thread exists but is hidden is itself a disclosure. Moderators keep seeing it, which
is how they restore it.
This deliberately does not use the period_lower idiom the rest of the schema uses for temporal
data (see user_sanctions). A period models a value that holds over an interval and may hold again
later over a new interval. A thread has exactly one state at a time, and restoring it is a return
to the normal state rather than a new interval. The full history of who deleted and who restored is
in the moderation log.
Post deletion is a different mechanism and is unchanged: a new revision with a null body.
An author edits their own last word
A moderator may rewrite any post at any time. An author may rewrite their own only while both of these hold:
- it is the last post of its thread — once someone has replied, rewriting the message rewrites what the reply is answering, and a reader cannot tell that happened;
- no moderator has already rewritten it — otherwise the author could quietly undo the moderation, and the moderator would have no way to know.
The second condition is read off the latest revision's author rather than off the whole history: once a moderator writes a revision, the author is locked out, so no later revision of theirs can follow, and the two readings cannot diverge.
ShortForumPost.self.can_edit carries the verdict, so a client draws the button if and only if the
server would accept the edit. GET /posts/:id/source is gated by the same rule: the Marktwin
source is what an editor needs, so being refused the edit means being refused the source.
The moderation log
Every moderation action writes a row to forum_moderation_log, and the write is done by
ForumService, never by a REST handler — that is the only way to be sure no path escapes it. If
the log write fails, the whole request fails: an unlogged moderation action is worse than a refused
one, because it is invisible exactly when it matters.
A row records the actor, the action, whichever of section / thread / post / target user applies, an
optional free-form comment, and a JSON data payload for the details that do not deserve a column
(the two sections of a move, the new value of a flag).
Its foreign keys are ON DELETE SET NULL, which is the one place this repository deliberately
breaks its CASCADE convention: deleting a section cascades to its threads and posts, and the
record of having moderated them has to survive that. The actor is ON DELETE RESTRICT — an
account that moderated something cannot be erased out from under the log.
Reading it is scoped: a moderator reads their own section (?section=…), and only an administrator
may read it unscoped, across every section. Otherwise a moderator of the quietest section could
read the whole site's moderation history through it.
The forum_moderation_action enum declares actions that no feature uses yet (DeletePost,
GrantRole, CreateSanction, ResolveReport, …). Postgres cannot both add an enum value and use
it in one transaction, and each migration edge runs in one transaction, so declaring the vocabulary
up front is what keeps a later feature from needing a migration of its own just to name itself.
REST
All under /api/v1/forum. The moderation endpoints answer with the updated ForumThread, so a
client never has to re-read what it just changed.
| Endpoint | Body / query |
|---|---|
PATCH /threads/:thread_ref | { title?, is_pinned?, is_locked? } — an absent field is left alone |
POST /threads/:thread_ref/section | { section } — move |
DELETE /threads/:thread_ref | { comment? } — the comment goes to the log, not to the author |
POST /threads/:thread_ref/restore | — |
GET /moderation_log | ?section&actor&offset&limit |
POST /posts/:id/reports, POST /threads/:ref/reports | { reason, body? } |
GET /reports | ?status§ion&offset&limit |
GET /reports/:report_id | — |
POST /reports/:report_id/resolution | { status, note? } |
GET/POST /sanctions | ?user§ion&active&offset&limit · { user, section?, kind, expires_at?, reason, internal_note } |
DELETE /sanctions/:sanction_id | lift |
POST/DELETE /role_grants | { user, role, section? } — section-less for a site-wide role |
Refusals are distinguished on purpose: 403 when the actor lacks the role, 404 when the thread or section does not exist or is hidden from this actor, 409 when the request is legitimate but the thread is already in the state it asks for (deleting a deleted thread, restoring a live one).
Reports
Any signed-in user may report a post or a thread. Reporting is deliberately not a privilege: the people who see abuse first are ordinary readers, and a queue only fills if they can fill it.
A report carries no section of its own. Its section is derived through its target's thread, so moving a thread carries its reports with it; a denormalised copy would go stale the first time a moderator moved something. The same person cannot pile up pending reports on one target, and once a report is resolved the target can be reported again — the queue is a workload, not a scoreboard.
Resolving is one-way. Accepted and Rejected both mean "dealt with" and differ only in what the
moderator concluded, which is worth keeping because it is the only feedback a reporter's judgement
ever gets. There is no reopening: the moderation log records what was decided and by whom, and a
report that could flip back and forth would make that record meaningless.
The queue is scoped like the log — a section moderator names their section, only an administrator or a global moderator reads every section at once. A report names the person who filed it, which is not something to leave open to the section next door.
Sanctions
A warning is an event: a record of an incident that changes no permission. A mute is a state: the user cannot write, in one section or across the forum.
The difference is in the schema. At most one mute may be live per user per scope — two overlapping ones would leave nobody able to say when the silence ends — while warnings stack, because a second incident deserves a second warning and a constraint refusing it would force a moderator to erase the first in order to record the second.
period and expires_at are two clocks and must not be collapsed into one. period closes when a
moderator lifts a sanction, which is attributable to them; expires_at is when a timed mute stops
on its own, which is attributable to nobody. "Is this user muted right now" is therefore both
conditions at once:
EXISTS (SELECT 1 FROM forum_sanctions
WHERE user_id = $1 AND kind = 'Mute' AND UPPER_INF(period)
AND (expires_at IS NULL OR expires_at > $now)
AND (forum_section_id IS NULL OR forum_section_id = $2))
The mute is resolved once, when a request's permissions are, rather than at each write: a silence enforced only where someone remembered to enforce it is not a silence. It gates the paths that say something — posting, replying, editing — and not moderation itself, because muting an administrator must not lock the site out of the tool that undoes the mute.
internal_note is written for other moderators and is omitted from what the sanctioned user reads.
They get the reason, which is the only way they learn why they cannot post.
Writing: Marktwin
Posts are written in Marktwin and stored twice: the
source in body, the rendered HTML in _html_body. A database CHECK keeps the pair consistent;
a half-written pair is treated as corrupt data and reported, not rendered.
The grammar the server accepts is defined in one place, ForumPermissions::grammar(), and is
served to the client in ForumSectionSelf.grammar rather than guessed. The editor
must offer exactly that grammar: markup the editor accepts and the server strips is silently lost
when the message is saved, which is how spoilers used to disappear between the preview and the post.
[mod] and [admin] blocks announce who is speaking, so they are granted by role: a moderator
gets mod, an administrator gets both. An OAuth token gets neither, whatever it was granted: a
forum:moderate token may pin, lock and hide, but an application speaking through a moderator's
account is not that moderator, and the blocks are about the voice rather than the commands.
Two flags stay off for a different reason. Marktwin declaresquote and spoiler in its
Grammar and documents their syntax, but version 0.5.0 implements neither: no token, no AST node,
no emitter branch. Turning them on would leave ||foo|| and [quote]foo[/quote] on screen as
literal text, so the toolbar buttons stay inert until the language grows them. The same holds for
underline, aside and roleplay: they are not in the grammar at all.
Marktwin emits div.mod for [mod] and div.mkt-admin for [admin] — not the
p.moderation-flag / p.admin-flag the stylesheet used to reach for.
An [admin] block is also how the server knows a thread carries an announcement:
forum_thread_meta.has_admin_announcement looks for div.mkt-admin in the stored HTML of each
post's latest revision. Derived rather than stored, so an announcement that is edited away stops
being advertised and no write path can forget to maintain a column.