# Design note — bulk moderation

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

---

## The job to be done

A forum accumulates forty topics that belong somewhere else, or a spam run
leaves thirty threads to remove. Doing that one topic at a time means forty
page loads, and the moderator either spends an afternoon on it or gives up and
leaves the forum untidy.

## Decision 1 — one shared implementation, not a second one for batches

The tempting shape is a bulk endpoint that writes its own `UPDATE ... WHERE id
IN (...)`. It is faster and it is wrong: the single-topic path logs to the
moderation log and dispatches an event, and a bulk path that skips either
produces topics whose history has a hole in it and webhooks that fire for some
moderation and not others.

So the per-topic work moves into a service, and both the existing single-topic
controller and the new bulk one call it. **A batch of thirty is thirty
individual moderations that happened to be asked for at once** — thirty log
rows, thirty events.

That costs thirty statements instead of one. For an action a moderator takes by
hand, on at most a page of topics, that is not a price worth optimising away
by making the audit trail worse.

## Decision 2 — the selection is what is on the page, and nothing more

Checkboxes select topics in the listing. There is deliberately **no "select
all topics matching this filter"**.

"Select all 4,000 topics in this forum and delete them" is a different feature
with a different blast radius, and it wants its own confirmation, its own
progress reporting and probably its own queue. A checkbox that quietly means
four thousand rows is how somebody empties a forum by accident.

The request is capped at 100 topics for the same reason — a page of topics is
the honest upper bound for what a checkbox selection should be able to do — and
anything over the cap is refused with a count, not silently truncated.

## Decision 3 — invisible to everybody who is not a moderator

Ordinary members see the topic list exactly as it is now. No checkbox column,
no dead space where one would be, nothing to explain.

For moderators the checkboxes are there but quiet, and **the action bar only
appears once something is selected** — an empty toolbar sitting over every
forum listing is clutter charged to every page view to serve the occasional
tidy-up.

## Decision 4 — a topic that cannot be acted on is skipped, not fatal

Somebody else may have deleted a topic between the page rendering and the form
submitting. A batch that aborts on the first missing row leaves the moderator
guessing which of the thirty went through.

So each is attempted independently, and the result says how many were done and
how many were skipped. Partial success is the normal outcome of acting on a
list that somebody else can also change.

## Decision 5 — destructive actions confirm, and moving asks where

Delete asks first, and says how many. Move needs a destination, so the action
bar carries a forum picker rather than sending the moderator to another screen
and losing the selection.

Moving also has to keep the counts honest: the source forum's topic count comes
down, the destination's goes up, and the topic's slug URL still resolves —
which it does, because a topic is addressed by its own id.

## What this does not do

- **No bulk post moderation.** Posts are selected inside a topic, which is a
  different screen and a different selection model. Worth doing; not this pass.
- **No undo.** Deleting is already soft — `deleted_at` is set, the row stays —
  so the data is recoverable, but there is no "undo that batch" button.
  Restoring is per topic.
- **No queue.** A hundred topics inside one request is fine. If the cap ever
  needs raising past that, the answer is a queue, not a longer request.
- **No merge or split.** Both are genuinely useful and both need a real UI for
  choosing what goes where. They are not batch operations wearing a different
  hat.
