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 how the sections are arranged, who may do what, and what the server records about it. None of it can be deduced from the schema alone.
Sections come in two levels
A section either holds threads or holds sections, and one column decides which: a section
whose parent_forum_section_id is NULL is a category. There is deliberately no kind column —
two columns saying the same thing eventually disagree, and they disagree at the worst possible
moment, when a category accepts a thread or a section refuses its own.
Category (parent is null) | Section (parent is set) | |
|---|---|---|
children | its sections, served by GET /sections/:section_ref | always empty |
threads | always an empty listing, count 0 | its threads, paged |
self.can_create_thread | always false | true for a member who may write |
The depth is exactly two, and the database is what holds it there rather than an if in Rust.
forum_sections carries two generated columns — is_root (the parent is NULL) and
parent_is_root (TRUE when there is a parent, NULL otherwise) — and a composite foreign key
back onto itself. Under Postgres's default MATCH SIMPLE a row with a NULL member satisfies the
key without being checked, so a category passes freely while a section must match a parent that is
itself a root. forum_threads carries the mirror of that trick: a constant section_is_root
column pinned to FALSE by a CHECK, and a composite foreign key, so no write path can put a
thread under a category. The parent key is ON DELETE RESTRICT — deleting a category would
otherwise silently take every section it holds, and through them every thread and every post.
Asking for a thread in a category is refused with 400 (section holds no threads), both when
creating one and when moving one into it.
Where a section comes from, and in what order
Sections are not created by an end user. POST /api/v1/forum/sections is reserved to the internal
caller, and the real writer is the seeding pass that runs at startup over
[seed.forum_section.<key>]:
[seed.forum_section.dinorpg] display_name = "DinoRPG" locale = "fr-FR" order = 30 [seed.forum_section.drpg_main] display_name = "[DinoRPG] Jurassic Park" locale = "fr-FR" parent = "dinorpg" order = 0
parent names another entry by its key, and seeding runs in two passes — every entry without a
parent first — so a category exists before the sections that hang under it. An unknown parent key
is refused (422 on the REST route).
The pass upserts by key and rewrites display_name, locale, parent and order on a section
that already exists, so re-parenting or re-ordering the forum is a configuration edit and a
restart, not a migration. Each of those fields has its own <field>_mtime, touched only when the
value actually changes.
order (forum_sections.display_order) has no uniqueness constraint: two sections sharing a rank
fall through to the next sort keys, whereas demanding distinct ranks would mean renumbering a whole
group to insert one heading in it. GET /sections returns the flattened tree rather than a flat
set — each category immediately followed by its own sections — ordered by the group's rank, then
its creation time, key and id, and within a group by the section's own rank and the same
tie-breakers. The order is total, which is what lets the listing be paged at all.
locale is descriptive. It is stored, served on every section and used by nothing: it does not
restrict who may post, nor pick the language of the interface.
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 | that section — and, when the section is a category, every section under it |
| user | being signed in | — |
A Moderator grant on a category is what makes a per-game moderation team possible: the grant is
written once against the category and applies to each of its sections, including ones added later.
Both stores resolve it the same way and the shared test suite holds them to it
(test_a_category_moderator_moderates_its_sections).
The scope of a grant is checked on the way in: Moderator needs a section, GlobalModerator and
Administrator must not carry one, and the mismatch is answered 400 rather than left to a
database CHECK.
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. Granting a role somebody already holds is a
no-op rather than an error: the caller asked for a state, not for a transition, and re-granting
must not restart the clock. Only an administrator may grant or revoke — a GlobalModerator
moderates everywhere but does not recruit, which are deliberately different powers. A moderator may
revoke their own role: stepping down needs no permission, though it still takes forum:moderate,
since an application authorized only to write messages does not get to take a moderator off duty.
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.
What the client is told
Every section and every thread carries a self block: what this actor may do here, decided by
the server. A section's block also carries the markup the actor may write and how much of the
section is new to them.
"self": {
"roles": [],
"grammar": { "admin": false, "bad": true, "big": true, "…": "…" },
"unread_threads": 0,
"can_create_thread": true
}
"self": {
"can_post": true, "can_lock": false, "can_pin": false,
"can_move": false, "can_delete": false, "can_report": true
}
A thread listing row carries the smallest block of all, {"is_unread": true}, and a post carries
can_edit, can_delete and can_report.
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 true for anyone who may write: reporting is deliberately not a privilege. It is
false for a guest and for a token below forum:write.
Unread threads
Two records answer "what is new", and they answer different questions.
forum_thread_read_markers holds how far one member has read one thread;
forum_read_floors holds one instant per member below which nothing counts as unread at all.
A thread is unread when its last post is newer than both:
last_post_ctime > GREATEST(COALESCE(marker.read_at, floor), floor)
A guest has neither row, the comparison is NULL, and a COALESCE turns it into false — nothing is
ever new to a guest, with no branch anywhere in the Rust.
The floor is not an optimisation. Without it, the day the feature ships turns years of archives
into unread mail for every account at once, and a signal nobody can act on is a signal everyone
learns to ignore. Accounts that existed when the table was created were backfilled at NOW();
newer ones get their floor from EnsureReadFloor, written on the first forum page they open. That
write inserts and never updates: a SetReadFloor on every page view would quietly mark the whole
forum as read.
Reading is recorded by an explicit write, never as a side effect of a GET. A link prefetch, a
crawler or a server-side render must not be able to mark a thread read on the member's behalf, so
the browser says so itself, once it has actually drawn the page, and only when signed in. It names
the last post of the page it drew, which is what makes opening page 1 of a ten-page thread
leave the thread unread.
POST /threads/:thread_ref/read takes a post id and not an instant — the server resolves the
post's ctime itself and checks the post belongs to the thread, so a client can neither claim to
be current on messages that do not exist nor mark a thread read up to tomorrow. A post from
another thread is answered 422: the client's own state is wrong, and no permission would have
made the request succeed. The write is read_at = GREATEST(read_at, ctime), and that monotonicity
is the whole safety argument: replays, duplicates and concurrent requests cannot move a marker
backwards, so the command needs no ordering guarantee from its caller.
POST /read_floor is "mark everything as read": it raises the floor to now and touches no marker,
so it stays a single write however large the forum grows. It also means markers that fall below
the floor may be purged at any time without changing an answer. Both routes answer with the state
the caller has to redraw — the thread's self block, and the refreshed section listing — rather
than making it ask again.
On the wire, ForumSectionSelf.unread_threads counts the section's unread threads and is computed
only by the section listing (GET /sections). Every other read leaves it at 0: a section page
already says which of its rows are new, and counting the page in front of the reader again would
be a second, differently-derived answer.
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. The destination must hold threads: moving into a category is refused.
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. A locked thread also stops its author from editing their own posts, and does not stop a moderator from editing anyone's.
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 — for everyone, moderators included — and reading it directly
returns 404, not 403, to anyone who is not a moderator: telling a stranger that a thread exists
but is hidden is itself a disclosure. A moderator still opens it by its URL, which is how they
restore it. Deleting a deleted thread, or restoring a live one, is 409.
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, written by a moderator, so the removal stays attributable. An author who wants their own message gone edits it or asks.
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.
A revision carries two bodies — the post's content and a moderator's moderation note — plus a
comment explaining the revision. The last two are moderator tools, and the edit page only shows
them to a moderator of the post's section; an ordinary author's submission omits them entirely,
so re-editing their own message cannot wipe an existing moderation note.
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 was declared in full up front, including the actions of features
that did not exist yet. 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 early is what keeps a later
feature from needing a migration of its own just to name itself. Most of it is now in use; what
remains unwritten is DeletePost and EditPost — post edits are their own history, kept as
revisions on the post rather than as lines in the log.
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 |
|---|---|
GET /sections | the whole tree, categories and their sections, in display order |
POST /sections | { key, display_name, locale?, parent?, order } — internal caller only; this is how the seed writes |
GET /sections/:section_ref | ?offset&limit over the threads (20 per page by default) |
POST /sections/:section_ref | { title, body } — open a thread |
POST/DELETE /sections/:section_ref/role_grants | { user } — add or remove a moderator of that section |
POST/DELETE /role_grants | { user, role, section? } — section-less for a site-wide role |
GET /threads/:thread_ref | ?offset&limit over the posts (10 per page by default) |
POST /threads/:thread_ref | { body } — reply |
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 | — |
POST /threads/:thread_ref/read | { up_to_post } — answers the thread's self block |
POST /read_floor | — mark everything read; answers the refreshed section listing |
GET /moderation_log | ?section&actor&offset&limit (at most 100 per page) |
GET/PATCH/DELETE /posts/:post_id | { last_revision_id, content?, moderation?, comment } · { last_revision_id, comment? } |
GET /posts/:post_id/source | the Marktwin source, for whoever may edit the post |
POST /posts/:post_id/reports, POST /threads/:thread_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 |
Refusals are distinguished on purpose: 401 when nobody is authenticated (or the credentials cannot be resolved), 403 when the actor lacks the role or the scope, 404 when the thread or section does not exist or is hidden from this actor, 409 when the request is legitimate but the target is already in the state it asks for (deleting a deleted thread, restoring a live one, a second pending report on the same target, a second live mute over the same scope), and 400 when no actor could have meant the request at all (a thread aimed at a category, a role at the wrong scope, a warning with an expiry).
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.
A stored report holds only a target id, which is not enough to judge it. What is served carries a
context alongside: the thread the target sits in, that thread's section, who wrote the reported
content and its rendered body. The target itself names its own kind — ForumPost or ForumThread
— which matters because the two references are otherwise the same shape, a tag and a uuid.
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. Resolving to Pending is
refused.
The queue is scoped like the log — a section moderator names their section, and only a global
moderator or an administrator 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. The moderation panel on the site
(/forum/moderation, with its log and sanctions pages) asks per section for that reason.
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.
A section moderator sanctions within their section; silencing someone across the whole forum takes
authority over the whole forum, so a site-wide sanction requires GlobalModerator or above.
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 version in use is 0.6.0, taken from git and pinned by revision in the workspace
Cargo.toml — crates.io still publishes 0.5.0 as its newest release. The website uses the matching
@eternaltwin/marktwin WebAssembly build for its live preview.
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.
The line runs between markup that is only formatting and markup that claims a role. Formatting is open to every member: it says how the text looks, and a reader who is misled by a bold word has not been misled about anything.
| Flag | Syntax | Who |
|---|---|---|
strong | **…** | every member |
emphasis | _…_ | every member |
underline | __…__ | every member |
strikethrough | ~~…~~ | every member |
big | [big]…[/big] | every member |
bad | [bad]…[/bad] | every member |
code | `…` and a fenced block | every member |
list | [ul], [ol], [li] | every member |
quote | [quote=author]…[/quote] | every member |
rp | [rp=author]…[/rp] | every member |
sidenote | [aparte]…[/aparte] | every member |
spoiler | ||…|| | every member |
collapse | [collapse=label]…[/collapse] | every member |
links | http and https only | every member |
mod | [mod]…[/mod] | moderator, session only |
animation | [animation]…[/animation] | moderator, session only |
announcement | [announcement]…[/announcement] | moderator, session only |
admin | [admin]…[/admin] | administrator, session only |
user | @{userId} | nobody, for now |
icons | — | nobody, for now |
Nesting is capped at a depth of four.
The four staff blocks announce who is speaking, so they are granted by role — and only to a browser
session. An OAuth token gets none of them 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. Each one is tied to the
level that earns it and nothing above it: a moderator speaks as the moderation team and as the
animation team, never as the administration.
user is off for neither reason. @{userId} renders as an empty span, and until a client can
turn a user id into a name, enabling it would offer markup that vanishes on screen. Mentions and
the help button are the last two inert buttons in the editor; every other button is drawn from the
served grammar, and a mark the server would refuse from this actor is left out of the toolbar
rather than shown greyed out.
Rendered Marktwin has a stylesheet of its own, packages/website/src/styles/_marktwin.scss,
holding every mkt-* class next to the grammar that decides them. Since 0.6.0, [mod] emits
div.mkt-mod; the pre-0.6.0 div.mod is kept as a second selector because a revision stores its
rendered HTML alongside its source, so every moderator post written before the upgrade would
otherwise lose its frame.
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. It cannot be forged either:
[admin] is gated on the grammar, and Marktwin escapes every < in ordinary text.