Skip to content

fix(moderation): log the moderation the audit trail claimed it already had - #122

Merged
jouwdan merged 1 commit into
mainfrom
claude/mei52-log-coverage
Aug 16, 2026
Merged

fix(moderation): log the moderation the audit trail claimed it already had#122
jouwdan merged 1 commit into
mainfrom
claude/mei52-log-coverage

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

MEI-52, finding D. /admin/log says it holds "every administrative and moderation action" and the ModCP offers itself as the record of a forum. Neither was true.

What was already true, before changing anything

The finding said no moderation action writes an admin_log row via recordAdminAction. That is literally true and misleading: the moderation writers do not use that helper because they write the row in the transaction that makes the changelogModeratorAction in packages/db/src/thread-counters.ts, called from the queue, the thread tools, thread surgery, inline moderation and the warning repository. That is stronger than recordAdminAction, which is a separate write that cannot roll back with the change it describes. So moderateQueueAction, inlineModerateAction, splitThreadAction/splitSelectedAction/mergeThreadAction, threadToolAction and issueWarningAction/revokeWarningAction were already logged and already visible on both screens. The copy was not wrong about them.

It was wrong about everything below.

Gap by gap, and the decision

Gap Decision Why
report.resolve/report.reject were labels nothing ever wrote (a) write the row Closing a report is the most consequential thing the report queue does and it left only a report_events row, invisible to anyone not opening that one report. Written in PostgresReportRepository.close, in the same transaction as the status change and the event, carrying the report's forum as forumId/forumIds. A report about a member or a PM has no forum and so carries no forum key — under #113's rule that makes it its actor's own business, which is right: nobody moderates "no forum".
Single-post soft-delete/restore from the postbit was silent (a) write the row post.delete/post.restore, written in applyVisibility beside the counter move, scoped to the post's forum.
Moderator edits of other members' posts were silent (a) write the row post.edit, written in applyEdit. The row carries the post, thread and forum — never the body or the edit reason.
Signature and avatar lock/unlock were silent (a) write the row signature.lock/signature.unlock and avatar.lock/avatar.unlock via recordAdminAction, which also records the address prefix. Four action names rather than one with a flag, following #113's lock/unlock split: "Locked a signature" has to be true on its own. The reason shown to the member stays out of the log.
continueMassMailAction was silent while its sibling logged (a) write the row user.mass_mail_continued, with massMailId, this batch's sent and the running queued. A 3,000-recipient campaign was one row and then silence for five further batches. Not in the moderator vocabulary — it is administration.
thread.copy wrote a row that MOD_LOG_ACTIONS filtered out (a) allow-list it, and fix its scope It also predated #113: its detail had toForumId but no forumIds and no source forum, so a copy would have reached only the destination's moderators. It now carries fromForumId/toForumId/forumIds, so both ends see it, and one forum once when a thread is copied within its own forum.
The /admin/log lede and the overview card (b) leave the copy alone With the rows above written, the claim holds. Correcting it downwards would have been the cheap way out of a defect that was really about missing rows.

Two things are deliberately still unlogged, and both are documented rather than left to be rediscovered:

  • A member deleting or editing their own post. The row is written when the actor is not the post's author — the same question the postbit asks to decide whether it is showing a moderator's button. Logging self-deletes would bury the moderation in them.
  • Report assignment. Taking a report or putting it back moves nothing, and the report's own timeline already shows who holds it. This is the one place the "noise" argument won.

Everything reuses the existing shapes: recordAdminAction for the app-level actions, logModeratorAction for the transactional ones, and #113's forumIds/forumId/fromForumId/toForumId keys for anything that belongs in the moderator log. No new detail keys — every key the new rows carry already had a label in DETAIL_LABELS.

Validation

  • pnpm verifyexit 0 (310 test files, 5,745 tests).
  • DATA_SOURCE=fixture pnpm buildexit 0. Run because apps/community/src/server/moderation-actions.ts gained an import of ./admin, which is server-only.
  • Every new test was proved able to fail. Reverting the six changed source files to main in two batches turned 22 of the new assertions red — the report rows, the copy's forum keys, the post rows, the two vocabulary tests, the two lock actions, the mass-mail batch row, and the three ModCP scoping tests — then all restored to green. The negative tests (a member's own delete and own edit logging nothing) stay green in both directions by construction, which is the point of them.
  • No e2e spec was changed, and pnpm test:e2e was not run.
  • Rebased onto main at 41ea6bd after verify; the affected suites and eslint were re-run on the rebased tree.

Docs updated in the same commit: a new parity entry under The moderator log stating what is logged, where the row is written and where the authorship boundary is; and a "What reaches the admin log" section in docs/operating.md naming the two exclusions, so an operator who cannot find a row knows in ten seconds whether it should exist.

🤖 Generated with Claude Code


Generated by Claude Code

…y had

/admin/log says it holds "every administrative and moderation action" and the
ModCP offers itself as the record of a forum. Both were short. The moderation
tools do write to admin_log — in the transaction that makes the change, which
is stronger than the control panel's helper — but several consequential paths
wrote nothing at all, and one that did write was missing from the ModCP's
allow-list and so was never shown.

Closing a report wrote only a report_event, so report.resolve and report.reject
were labels for rows nothing produced; they are now written beside the event, in
the same transaction, carrying the report's forum as forumIds/forumId. Deleting,
restoring or editing a single post from the postbit wrote nothing; it now writes
post.delete, post.restore or post.edit, scoped to the post's forum, when the
actor is not the post's author — a member acting on their own post is not
moderation and stays out. Locking a signature or an avatar now records
signature.lock/unlock and avatar.lock/unlock through recordAdminAction, so the
address is recorded too. Each further batch of a mass mail records
user.mass_mail_continued, so a campaign is no longer one row and then silence.
thread.copy joins the allow-list and gains the forum keys #113 established, so a
copy reaches the moderators of both the forum it came from and the one it landed
in rather than nobody.

Report assignment stays unlogged: it moves nothing and the report's own timeline
already shows who holds it. Both copies stand as written, because the record now
matches them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kP43A5shJWmAKqkBovhaF
@jouwdan
jouwdan merged commit 3d9a82b into main Aug 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants