moderate 0.1.0 → 1.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/.simplecov +62 -0
  4. data/AGENTS.md +7 -0
  5. data/Appraisals +16 -0
  6. data/CHANGELOG.md +71 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +376 -29
  9. data/Rakefile +28 -2
  10. data/app/controllers/concerns/moderate/moderation.rb +161 -0
  11. data/app/controllers/moderate/appeals_controller.rb +190 -0
  12. data/app/controllers/moderate/application_controller.rb +45 -0
  13. data/app/controllers/moderate/notices_controller.rb +382 -0
  14. data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
  15. data/app/helpers/moderate/engine_helper.rb +151 -0
  16. data/app/views/moderate/appeals/new.html.erb +78 -0
  17. data/app/views/moderate/notices/new.html.erb +255 -0
  18. data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
  19. data/app/views/moderate/transparency_reports/show.html.erb +52 -0
  20. data/config/moderate/blocklists/en.yml +81 -0
  21. data/config/moderate/blocklists/es.yml +40 -0
  22. data/config/routes.rb +36 -0
  23. data/docs/compliance.md +178 -0
  24. data/docs/configuration.md +326 -0
  25. data/docs/dsa-notice-form.md +371 -0
  26. data/docs/madmin.md +490 -0
  27. data/docs/notifications.md +363 -0
  28. data/examples/aws_rekognition_adapter.rb +140 -0
  29. data/examples/openai_moderation_adapter.rb +111 -0
  30. data/gemfiles/rails_7.1.gemfile +36 -0
  31. data/gemfiles/rails_7.2.gemfile +36 -0
  32. data/gemfiles/rails_8.1.gemfile +36 -0
  33. data/lib/generators/moderate/install_generator.rb +56 -0
  34. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  35. data/lib/generators/moderate/templates/initializer.rb +198 -0
  36. data/lib/generators/moderate/views_generator.rb +63 -0
  37. data/lib/moderate/configuration.rb +341 -0
  38. data/lib/moderate/engine.rb +138 -0
  39. data/lib/moderate/errors.rb +26 -0
  40. data/lib/moderate/event.rb +75 -0
  41. data/lib/moderate/filters/base.rb +126 -0
  42. data/lib/moderate/filters/wordlist.rb +255 -0
  43. data/lib/moderate/jobs/classify_job.rb +158 -0
  44. data/lib/moderate/label.rb +111 -0
  45. data/lib/moderate/macros.rb +90 -0
  46. data/lib/moderate/models/appeal.rb +154 -0
  47. data/lib/moderate/models/application_record.rb +31 -0
  48. data/lib/moderate/models/block.rb +203 -0
  49. data/lib/moderate/models/concerns/actor.rb +174 -0
  50. data/lib/moderate/models/concerns/content_filterable.rb +155 -0
  51. data/lib/moderate/models/concerns/reportable.rb +282 -0
  52. data/lib/moderate/models/flag.rb +136 -0
  53. data/lib/moderate/models/report.rb +620 -0
  54. data/lib/moderate/result.rb +176 -0
  55. data/lib/moderate/services/intake_appeal.rb +89 -0
  56. data/lib/moderate/services/intake_notice.rb +132 -0
  57. data/lib/moderate/services/intake_report.rb +132 -0
  58. data/lib/moderate/services/resolve_appeal.rb +134 -0
  59. data/lib/moderate/services/resolve_flag.rb +101 -0
  60. data/lib/moderate/services/resolve_report.rb +291 -0
  61. data/lib/moderate/version.rb +1 -1
  62. data/lib/moderate.rb +365 -18
  63. data/log/development.log +0 -0
  64. data/log/test.log +0 -0
  65. metadata +154 -15
data/docs/madmin.md ADDED
@@ -0,0 +1,490 @@
1
+ # Admin & the moderation queue — wiring `moderate` into `madmin` (BYOUI)
2
+
3
+ Most of Trust & Safety lives in *admin*: a human (or a script) looking at a queue of reports, flags, and appeals and making a call. `moderate` deliberately does **not** ship admin chrome — it ships the **primitives** (the `Moderate::Report` / `Moderate::Flag` / `Moderate::Appeal` / `Moderate::Block` models, the `resolve!` / `dismiss!` / `uphold!` / `reject!` decision methods, the queue scopes, the controller concern), and lets you **bring your own UI**.
4
+
5
+ This guide is the full walkthrough for the most common BYOUI choice: [`madmin`](https://github.com/excid3/madmin). It's the exact recipe a real production app uses, distilled. If you're on ActiveAdmin, Avo, Trestle, or a hand-rolled admin, the *shape* is identical — generate a screen against the model, add two buttons, POST to a custom action that calls the gem's decision method. Only the framework glue changes.
6
+
7
+ > [!NOTE]
8
+ > Why no built-in admin? Trust & Safety UI is the part every app wants to own — your branding, your auth, your layout, your extra columns. The decision *logic* (atomic content removal, ban, notify, audit) is the part nobody should reimplement. `moderate` draws the line exactly there: the gem owns the `resolve!`/`dismiss!`/`uphold!`/`reject!` transaction; you own the screen that calls it. See [What `moderate` does and doesn't do](../README.md#what-moderate-does-and-doesnt-do).
9
+
10
+ ---
11
+
12
+ ## TL;DR
13
+
14
+ ```bash
15
+ # 1. Generate one madmin resource per moderation model
16
+ rails generate madmin:resource Moderate::Report
17
+ rails generate madmin:resource Moderate::Flag
18
+ rails generate madmin:resource Moderate::Appeal
19
+ rails generate madmin:resource Moderate::Block
20
+ ```
21
+
22
+ ```ruby
23
+ # 2. Add member routes for the decisions (config/routes.rb, inside your madmin namespace)
24
+ resources :reports, only: [:index, :show] do
25
+ member { post :resolve; post :dismiss }
26
+ end
27
+ resources :flags, only: [:index, :show] do
28
+ member { post :resolve; post :dismiss }
29
+ end
30
+ resources :appeals, only: [:index, :show] do
31
+ member { post :uphold; post :reject }
32
+ end
33
+ resources :blocks, only: [:index, :show]
34
+ ```
35
+
36
+ ```ruby
37
+ # 3. Subclass the madmin resource controller and call the gem's decision methods
38
+ module Madmin
39
+ class ReportsController < Madmin::ResourceController
40
+ def resolve
41
+ @record.resolve!(by: current_user, remove_content: params[:remove_content],
42
+ ban_user: params[:ban_user], note: params[:note])
43
+ redirect_to main_app.madmin_report_path(@record), notice: "Report resolved.", status: :see_other
44
+ rescue => e
45
+ redirect_to main_app.madmin_report_path(@record), alert: "Could not resolve: #{e.message}", status: :see_other
46
+ end
47
+
48
+ def dismiss
49
+ @record.dismiss!(by: current_user, note: params[:note])
50
+ redirect_to main_app.madmin_report_path(@record), notice: "Report dismissed.", status: :see_other
51
+ rescue => e
52
+ redirect_to main_app.madmin_report_path(@record), alert: "Could not dismiss: #{e.message}", status: :see_other
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ That's the whole pattern. The rest of this doc fills in the resource definitions, the queue, the decision buttons, and the gotchas.
59
+
60
+ ---
61
+
62
+ ## The model primitives `madmin` points at
63
+
64
+ `moderate`'s models are **plain ActiveRecord**, so they show up in `madmin` like any other model — no special integration. Here's what you're admining and the queue scope on each:
65
+
66
+ | Model | What it is | The queue scope | Decide with |
67
+ | --- | --- | --- | --- |
68
+ | `Moderate::Report` | In-app reports **and** public DSA notices (one table, distinguished by `intake_kind`) | `Moderate::Report.pending` | `report.resolve!` / `report.dismiss!` |
69
+ | `Moderate::Flag` | Auto-filter flags from `:flag`-mode `moderates` (source: `text_filter` / `image_filter` / `external_classifier` / `manual`) | `Moderate::Flag.pending` | `flag.resolve!` / `flag.dismiss!` |
70
+ | `Moderate::Appeal` | DSA Art. 20 internal complaints against a decision | `Moderate::Appeal.pending` | `appeal.uphold!` / `appeal.reject!` |
71
+ | `Moderate::Block` | The bidirectional `blocker`/`blocked` safety edge | (no decision — read-only) | n/a |
72
+
73
+ The same `pending` scope is what a **human admin** reads in `madmin` *and* what an **automated ML consumer** reads in a background job — one queue, two readers. (More on that in [Automated review](#automated-review-the-same-pending-queue).)
74
+
75
+ > [!IMPORTANT]
76
+ > Decisions are **only** ever made by calling the gem's methods (`resolve!`, `dismiss!`, `uphold!`, `reject!`). Don't let madmin's stock edit form mutate `status` directly. Every decision is atomic, requires a moderator + a note, runs your enforcement (content removal via the reportable's own `remove_reported_field!`, bans via your `ban_handler`), fires the `notify` / `audit` hooks, and stamps the appeal window. A raw `status = "resolved"` update skips the audited decision workflow. Keep the models **read-only** in madmin (`form: false`) and route every change through a custom member action — exactly what this guide does.
77
+
78
+ ---
79
+
80
+ ## Step 1 — Generate the resources
81
+
82
+ `madmin`'s generator works against namespaced models out of the box:
83
+
84
+ ```bash
85
+ rails generate madmin:resource Moderate::Report
86
+ rails generate madmin:resource Moderate::Flag
87
+ rails generate madmin:resource Moderate::Appeal
88
+ rails generate madmin:resource Moderate::Block
89
+ ```
90
+
91
+ Each creates `app/madmin/resources/moderate/<model>_resource.rb` and a flat controller stub. Now edit the resources to make them a real moderation queue — read-only columns, queue scopes, and a useful index order.
92
+
93
+ ### `Moderate::ReportResource`
94
+
95
+ ```ruby
96
+ # app/madmin/resources/moderate/report_resource.rb
97
+ class Moderate::ReportResource < Madmin::Resource
98
+ model Moderate::Report
99
+
100
+ # Everything is read-only (form: false): decisions go through the custom
101
+ # member actions below, never through madmin's stock edit form.
102
+ attribute :id, index: true, form: false
103
+ attribute :status, index: true, form: false # pending / resolved / dismissed
104
+ attribute :kind, index: true, form: false # "report" (in-app) or "dsa_notice"
105
+ attribute :category, index: true, form: false # community category OR DSA legal_reason
106
+ attribute :reportable, :polymorphic, index: true, form: false, label: "Target"
107
+ attribute :reported_field, index: true, form: false, label: "Field"
108
+ attribute :reported_user, index: true, form: false
109
+ attribute :reporter, index: true, form: false
110
+ attribute :notifier_email, index: true, form: false, label: "Notifier" # DSA notices
111
+ attribute :details, index: false, form: false
112
+ attribute :snapshot, index: false, form: false # the immutable evidence snapshot
113
+ attribute :resolution_note, index: false, form: false
114
+ attribute :created_at, index: true, form: false, label: "Received"
115
+ attribute :resolved_at, index: true, form: false
116
+
117
+ # Queue scopes — these are the gem's own scopes, surfaced as madmin filters.
118
+ scope :pending
119
+ scope :resolved
120
+ scope :dismissed
121
+
122
+ menu label: "Reports", parent: "Trust & Safety"
123
+
124
+ def self.display_name(record) = "Report ##{record.id.to_s.first(8)}"
125
+ def self.default_sort_column = "created_at"
126
+ def self.default_sort_direction = "desc"
127
+ end
128
+ ```
129
+
130
+ ### `Moderate::FlagResource`
131
+
132
+ ```ruby
133
+ # app/madmin/resources/moderate/flag_resource.rb
134
+ class Moderate::FlagResource < Madmin::Resource
135
+ model Moderate::Flag
136
+
137
+ attribute :id, index: true, form: false
138
+ attribute :status, index: true, form: false # pending / resolved / dismissed
139
+ attribute :source, index: true, form: false # wordlist / image / <your adapter> / manual
140
+ attribute :flaggable, :polymorphic, index: true, form: false, label: "Target"
141
+ attribute :field, index: true, form: false
142
+ attribute :owner, index: true, form: false
143
+ attribute :categories, index: false, form: false # e.g. [:hate, :threats]
144
+ attribute :scores, index: false, form: false # { hate: 1.0 } (0..1 for ML adapters)
145
+ attribute :resolution_note, index: false, form: false
146
+ attribute :created_at, index: true, form: false
147
+ attribute :reviewed_at, index: true, form: false
148
+
149
+ scope :pending
150
+ scope :resolved
151
+ scope :dismissed
152
+
153
+ menu label: "Flags", parent: "Trust & Safety"
154
+
155
+ def self.display_name(record) = "Flag ##{record.id.to_s.first(8)}"
156
+ def self.default_sort_column = "created_at"
157
+ def self.default_sort_direction = "desc"
158
+ end
159
+ ```
160
+
161
+ ### `Moderate::AppealResource`
162
+
163
+ ```ruby
164
+ # app/madmin/resources/moderate/appeal_resource.rb
165
+ class Moderate::AppealResource < Madmin::Resource
166
+ model Moderate::Appeal
167
+
168
+ attribute :id, index: true, form: false
169
+ attribute :status, index: true, form: false # pending / upheld / rejected
170
+ attribute :report, index: true, form: false # the decision being appealed
171
+ attribute :appellant_email, index: true, form: false
172
+ attribute :reason, index: false, form: false
173
+ attribute :resolution_note, index: false, form: false
174
+ attribute :created_at, index: true, form: false, label: "Received"
175
+ attribute :resolved_at, index: true, form: false
176
+
177
+ scope :pending
178
+ scope :upheld
179
+ scope :rejected
180
+
181
+ menu label: "Appeals", parent: "Trust & Safety"
182
+
183
+ def self.display_name(record) = "Appeal ##{record.id.to_s.first(8)}"
184
+ def self.default_sort_column = "created_at"
185
+ def self.default_sort_direction = "desc"
186
+ end
187
+ ```
188
+
189
+ ### `Moderate::BlockResource` (read-only)
190
+
191
+ Blocks have no decision — they're a user safety edge, so they're a plain read-only list for support visibility:
192
+
193
+ ```ruby
194
+ # app/madmin/resources/moderate/block_resource.rb
195
+ class Moderate::BlockResource < Madmin::Resource
196
+ model Moderate::Block
197
+
198
+ attribute :id, index: true, form: false
199
+ attribute :blocker, index: true, form: false
200
+ attribute :blocked, index: true, form: false
201
+ attribute :created_at, index: true, form: false
202
+
203
+ menu label: "Blocks", parent: "Trust & Safety"
204
+
205
+ def self.display_name(record) = "Block ##{record.id.to_s.first(8)}"
206
+ end
207
+ ```
208
+
209
+ > [!TIP]
210
+ > Group all four under one `parent:` (here `"Trust & Safety"`) so they sit together in the madmin sidebar — that grouping *is* your moderation queue's navigation.
211
+
212
+ ---
213
+
214
+ ## Step 2 — Routes for the decisions
215
+
216
+ `madmin` gives you `index`/`show` for free. The decisions are custom **member** actions you add yourself, the standard Rails way. Inside your madmin namespace:
217
+
218
+ ```ruby
219
+ # config/routes.rb
220
+ namespace :madmin do
221
+ resources :reports, only: [:index, :show] do
222
+ member do
223
+ post :resolve
224
+ post :dismiss
225
+ end
226
+ end
227
+
228
+ resources :flags, only: [:index, :show] do
229
+ member do
230
+ post :resolve
231
+ post :dismiss
232
+ end
233
+ end
234
+
235
+ resources :appeals, only: [:index, :show] do
236
+ member do
237
+ post :uphold
238
+ post :reject
239
+ end
240
+ end
241
+
242
+ resources :blocks, only: [:index, :show] # read-only
243
+ end
244
+ ```
245
+
246
+ This gives you `resolve_madmin_report_path(report)`, `dismiss_madmin_report_path(report)`, `uphold_madmin_appeal_path(appeal)`, and friends — the URLs your decision buttons POST to.
247
+
248
+ > [!NOTE]
249
+ > Keep `only: [:index, :show]`. There's no `:edit`/`:update`/`:destroy` because **the only legitimate way to change a moderation record is a decision method**, and those live behind the member actions, not the stock REST update.
250
+
251
+ ---
252
+
253
+ ## Step 3 — The controller (call the gem's decision methods)
254
+
255
+ This is the heart of the integration, and it's tiny. Subclass `Madmin::ResourceController`, add one action per decision, and have each action call the matching `moderate` method. `@record` is set for you by `madmin` (it's the report/flag/appeal the member route resolved).
256
+
257
+ ```ruby
258
+ # app/controllers/madmin/reports_controller.rb
259
+ module Madmin
260
+ class ReportsController < Madmin::ResourceController
261
+ def resolve
262
+ @record.resolve!(
263
+ by: current_user, # the moderator (required)
264
+ remove_content: params[:remove_content], # runs reportable#remove_reported_field!
265
+ ban_user: params[:ban_user], # runs your config.ban_handler
266
+ note: params[:note] # required — the decision rationale
267
+ )
268
+ redirect_to main_app.madmin_report_path(@record),
269
+ notice: "Report resolved.", status: :see_other
270
+ rescue => error
271
+ redirect_to main_app.madmin_report_path(@record),
272
+ alert: "Could not resolve report: #{error.message}", status: :see_other
273
+ end
274
+
275
+ def dismiss
276
+ @record.dismiss!(by: current_user, note: params[:note])
277
+ redirect_to main_app.madmin_report_path(@record),
278
+ notice: "Report dismissed.", status: :see_other
279
+ rescue => error
280
+ redirect_to main_app.madmin_report_path(@record),
281
+ alert: "Could not dismiss report: #{error.message}", status: :see_other
282
+ end
283
+
284
+ private
285
+
286
+ # Eager-load the associations the index/show touch, so the queue page
287
+ # doesn't N+1 across reporter / reported_user / target.
288
+ def scoped_resources
289
+ super.includes(:reporter, :reported_user, :reportable)
290
+ end
291
+ end
292
+ end
293
+ ```
294
+
295
+ ```ruby
296
+ # app/controllers/madmin/flags_controller.rb
297
+ module Madmin
298
+ class FlagsController < Madmin::ResourceController
299
+ def resolve
300
+ @record.resolve!(by: current_user, note: params[:note])
301
+ redirect_to main_app.madmin_flag_path(@record), notice: "Flag actioned.", status: :see_other
302
+ rescue => error
303
+ redirect_to main_app.madmin_flag_path(@record), alert: "Could not action flag: #{error.message}", status: :see_other
304
+ end
305
+
306
+ def dismiss
307
+ @record.dismiss!(by: current_user, note: params[:note])
308
+ redirect_to main_app.madmin_flag_path(@record), notice: "Flag dismissed.", status: :see_other
309
+ rescue => error
310
+ redirect_to main_app.madmin_flag_path(@record), alert: "Could not dismiss flag: #{error.message}", status: :see_other
311
+ end
312
+
313
+ private
314
+
315
+ def scoped_resources
316
+ super.includes(:flaggable, :owner)
317
+ end
318
+ end
319
+ end
320
+ ```
321
+
322
+ ```ruby
323
+ # app/controllers/madmin/appeals_controller.rb
324
+ module Madmin
325
+ class AppealsController < Madmin::ResourceController
326
+ def uphold
327
+ @record.uphold!(by: current_user, note: params[:note]) # overturns the original decision
328
+ redirect_to main_app.madmin_appeal_path(@record), notice: "Appeal upheld.", status: :see_other
329
+ rescue => error
330
+ redirect_to main_app.madmin_appeal_path(@record), alert: "Could not uphold appeal: #{error.message}", status: :see_other
331
+ end
332
+
333
+ def reject
334
+ @record.reject!(by: current_user, note: params[:note]) # confirms the original decision
335
+ redirect_to main_app.madmin_appeal_path(@record), notice: "Appeal rejected.", status: :see_other
336
+ rescue => error
337
+ redirect_to main_app.madmin_appeal_path(@record), alert: "Could not reject appeal: #{error.message}", status: :see_other
338
+ end
339
+
340
+ private
341
+
342
+ def scoped_resources
343
+ super.includes(:report, :appellant)
344
+ end
345
+ end
346
+ end
347
+ ```
348
+
349
+ Notice what's **not** here: no content-removal SQL, no `user.suspend!`, no email sending, no audit write. All of that is the gem's job, triggered atomically inside `resolve!` / `dismiss!` / `uphold!` / `reject!`. Your controller is pure HTTP — params in, decision method called, redirect out. (That's why every action is a four-liner with a `rescue` for the flash.)
350
+
351
+ > [!IMPORTANT]
352
+ > Use `status: :see_other` on the redirects. The decision actions are `POST`s, and Turbo needs a 303 to follow a redirect after a non-GET. This is the same convention madmin's own create/update use.
353
+
354
+ ### Reuse from the standard `Moderate::Moderation` concern instead
355
+
356
+ If you'd rather not hand-write the four controllers, `moderate` ships a controller concern that gives you the `resolve`/`dismiss` (and appeal `uphold`/`reject`) actions, strong params, and redirects already wired — you just bring auth:
357
+
358
+ ```ruby
359
+ module Madmin
360
+ class ReportsController < Madmin::ResourceController
361
+ include Moderate::Moderation # resolve!/dismiss! actions, strong params, redirects
362
+ end
363
+ end
364
+ ```
365
+
366
+ Hand-rolling (above) is the right call when your redirects/flashes need to match the rest of your madmin app; the concern is the right call when you want zero boilerplate. They do the same thing.
367
+
368
+ ---
369
+
370
+ ## Step 4 — The decision buttons (the show view)
371
+
372
+ `madmin`'s stock `show` template lists attributes; add a small panel with the decision forms. The cleanest move is a custom show view at `app/views/madmin/reports/show.html.erb` that renders madmin's default attributes and then a "Decide" sidebar. The load-bearing part is just two forms that POST to the member routes — render them only while the record is still `pending`:
373
+
374
+ ```erb
375
+ <%# app/views/madmin/reports/show.html.erb (decision panel; render alongside madmin's default attribute list) %>
376
+ <% report = @record %>
377
+
378
+ <% if report.pending? %>
379
+ <section>
380
+ <h2>Resolve</h2>
381
+ <%= form_with url: resolve_madmin_report_path(report), method: :post do %>
382
+ <label><%= check_box_tag :remove_content, "1" %> Remove reported content</label>
383
+ <label><%= check_box_tag :ban_user, "1" %> Ban reported user</label>
384
+ <%= text_area_tag :note, nil, placeholder: "Decision note", required: true %>
385
+ <%= submit_tag "Resolve", data: { turbo_confirm: "Resolve and notify the parties?" } %>
386
+ <% end %>
387
+
388
+ <h2>Dismiss</h2>
389
+ <%= form_with url: dismiss_madmin_report_path(report), method: :post do %>
390
+ <%= text_area_tag :note, nil, placeholder: "Decision note", required: true %>
391
+ <%= submit_tag "Dismiss", data: { turbo_confirm: "Dismiss and notify the reporter?" } %>
392
+ <% end %>
393
+ </section>
394
+ <% else %>
395
+ <p>This report is closed.</p>
396
+ <% end %>
397
+ ```
398
+
399
+ The appeal show view is the same shape with `uphold`/`reject`:
400
+
401
+ ```erb
402
+ <% appeal = @record %>
403
+ <% if appeal.pending? %>
404
+ <%= form_with url: uphold_madmin_appeal_path(appeal), method: :post do %>
405
+ <%= text_area_tag :note, nil, placeholder: "Why the original decision is overturned", required: true %>
406
+ <%= submit_tag "Uphold appeal", data: { turbo_confirm: "Overturn the original decision?" } %>
407
+ <% end %>
408
+ <%= form_with url: reject_madmin_appeal_path(appeal), method: :post do %>
409
+ <%= text_area_tag :note, nil, placeholder: "Why the original decision stands", required: true %>
410
+ <%= submit_tag "Reject appeal", data: { turbo_confirm: "Confirm the original decision?" } %>
411
+ <% end %>
412
+ <% end %>
413
+ ```
414
+
415
+ Two details worth copying:
416
+
417
+ - **Gate on `pending?`.** Show the decision forms only while the record is open; render "closed" once it isn't. The decision methods are also guarded server-side (calling `resolve!` on an already-resolved report raises), but hiding the buttons is the better UX.
418
+ - **Require the note.** `required: true` on the textarea, and the gem requires it too — every decision must carry a rationale (that's what feeds the DSA Art. 17 statement of reasons). Belt and suspenders.
419
+
420
+ The evidence snapshot (`report.snapshot`) is plain JSON on the record — render it in a `<pre>` so the moderator sees exactly what was reported, even if the original content was since edited or deleted. That immutability is the whole point of the snapshot.
421
+
422
+ ---
423
+
424
+ ## The moderation-queue pattern (the index)
425
+
426
+ Your `index` *is* the queue. Three things make it usable:
427
+
428
+ 1. **Default to pending.** The `scope :pending` you declared on each resource gives madmin a one-click filter; make it the landing view by sorting `created_at desc` and pointing your "Moderation" nav link at `madmin_reports_path(scope: :pending)`.
429
+ 2. **One sidebar group.** All four resources under one `parent:` menu (`"Trust & Safety"`) so reports, flags, appeals, and blocks read as a single workspace.
430
+ 3. **A queue-depth dashboard tile.** The same scopes power an at-a-glance count on your admin home:
431
+
432
+ ```ruby
433
+ # app/controllers/madmin/dashboard_controller.rb
434
+ def show
435
+ @pending_reports = Moderate::Report.pending.count
436
+ @pending_flags = Moderate::Flag.pending.count
437
+ @pending_appeals = Moderate::Appeal.pending.count
438
+ end
439
+ ```
440
+
441
+ ```erb
442
+ <%= link_to "#{@pending_reports} reports awaiting review", madmin_reports_path(scope: :pending) %>
443
+ <%= link_to "#{@pending_flags} flags awaiting review", madmin_flags_path(scope: :pending) %>
444
+ <%= link_to "#{@pending_appeals} appeals awaiting review", madmin_appeals_path(scope: :pending) %>
445
+ ```
446
+
447
+ That's the whole moderation queue: pending-first lists, grouped nav, and a count on the dashboard — all built from the gem's `pending` scopes and your existing madmin.
448
+
449
+ ---
450
+
451
+ ## Automated review: the same `pending` queue
452
+
453
+ The headline trick of the model design: **a human admin and an ML/automation consumer read the *same* `Moderate::Flag.pending` (or `Moderate::Report.pending`) scope.** Your madmin screen is one reader; a background job is another. To auto-action high-confidence flags before a human ever sees them, drain the same queue in a job and call the same decision method:
454
+
455
+ ```ruby
456
+ class AutoModerationJob < ApplicationJob
457
+ def perform
458
+ Moderate::Flag.pending.find_each do |flag|
459
+ next unless flag.scores[:csam].to_f >= 0.99 # only the unambiguous ones
460
+ flag.resolve!(by: Moderate.system_actor, note: "Auto-removed: high-confidence CSAM")
461
+ end
462
+ end
463
+ end
464
+ ```
465
+
466
+ Because the job calls `resolve!` (not a raw `update`), the auto-decision is identical to a human one — atomic enforcement, `notify`/`audit` hooks, statement-of-reasons, appeal window. Whatever the job doesn't touch stays in `pending` for a human in madmin. One queue, two consumers, zero divergence.
467
+
468
+ ---
469
+
470
+ ## What `moderate` ships vs. what you build
471
+
472
+ To be explicit about the boundary this guide sits on:
473
+
474
+ | `moderate` ships (the primitives) | You build (the UI chrome) |
475
+ | --- | --- |
476
+ | The models (`Report`/`Flag`/`Appeal`/`Block`) | The madmin resources (columns, labels, fields) |
477
+ | The queue scopes (`.pending`, `.resolved`, …) | The index/show screens & nav grouping |
478
+ | The decision methods (`resolve!`/`dismiss!`/`uphold!`/`reject!`) — atomic enforcement + notify + audit + appeal window | The buttons/forms that call them |
479
+ | The optional `Moderate::Moderation` controller concern | Auth (`current_user`, admin gate) |
480
+ | Helpers + the evidence snapshot on each record | Your branding, layout, extra columns |
481
+
482
+ You wire it once and you have a real, audited moderation queue, in your own admin, in an afternoon.
483
+
484
+ ## See also
485
+
486
+ - [Admin & the moderation queue](../README.md#️-admin--the-moderation-queue) — the short version in the README
487
+ - [Configuration reference](configuration.md) — `ban_handler`, `notify`, `audit`, filter policies
488
+ - [Notifications & audit](../README.md#-notifications---audit--one-hook-each) — what fires when you call a decision method
489
+ - [The DSA notice form](dsa-notice-form.md) — the public Art. 16 intake that lands in the same `Moderate::Report.pending` queue
490
+ - [`madmin`](https://github.com/excid3/madmin) — the admin framework this guide targets