moderate 1.0.0.beta1 → 1.0.0

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +96 -1
  3. data/.simplecov +2 -10
  4. data/AGENTS.md +1 -1
  5. data/Appraisals +10 -8
  6. data/CHANGELOG.md +660 -20
  7. data/CLAUDE.md +1 -1
  8. data/README.md +149 -7
  9. data/Rakefile +2 -0
  10. data/app/controllers/concerns/moderate/moderation.rb +23 -11
  11. data/app/controllers/moderate/appeals_controller.rb +9 -5
  12. data/app/controllers/moderate/application_controller.rb +3 -3
  13. data/app/controllers/moderate/notices_controller.rb +3 -6
  14. data/app/helpers/moderate/engine_helper.rb +32 -11
  15. data/app/views/moderate/appeals/new.html.erb +6 -0
  16. data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
  17. data/app/views/moderate/transparency_reports/show.html.erb +5 -5
  18. data/config/locales/en.yml +200 -0
  19. data/config/locales/es.yml +177 -0
  20. data/context7.json +4 -0
  21. data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
  22. data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
  23. data/docs/chats.md +140 -0
  24. data/docs/compliance.md +92 -3
  25. data/docs/configuration.md +30 -2
  26. data/docs/dsa-notice-form.md +1 -1
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +44 -14
  29. data/docs/notifications.md +17 -4
  30. data/examples/aws_rekognition_adapter.rb +6 -6
  31. data/examples/openai_moderation_adapter.rb +1 -1
  32. data/examples/regexp_pattern_adapter.rb +68 -0
  33. data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
  34. data/gemfiles/rails_8.1.gemfile +6 -5
  35. data/lib/generators/moderate/install_generator.rb +25 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
  37. data/lib/generators/moderate/templates/initializer.rb +9 -15
  38. data/lib/generators/moderate/views_generator.rb +3 -3
  39. data/lib/moderate/attachments.rb +137 -0
  40. data/lib/moderate/configuration.rb +101 -12
  41. data/lib/moderate/engine.rb +86 -25
  42. data/lib/moderate/event.rb +21 -0
  43. data/lib/moderate/filters/base.rb +1 -0
  44. data/lib/moderate/filters/wordlist.rb +5 -2
  45. data/lib/moderate/jobs/classify_job.rb +100 -16
  46. data/lib/moderate/label.rb +2 -2
  47. data/lib/moderate/macros.rb +8 -3
  48. data/lib/moderate/models/appeal.rb +10 -1
  49. data/lib/moderate/models/block.rb +23 -19
  50. data/lib/moderate/models/classification.rb +24 -0
  51. data/lib/moderate/models/concerns/actor.rb +17 -2
  52. data/lib/moderate/models/concerns/content_filterable.rb +241 -56
  53. data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
  54. data/lib/moderate/models/concerns/reportable.rb +84 -4
  55. data/lib/moderate/models/flag.rb +199 -3
  56. data/lib/moderate/models/report.rb +180 -16
  57. data/lib/moderate/result.rb +3 -10
  58. data/lib/moderate/retention.rb +241 -0
  59. data/lib/moderate/services/intake_notice.rb +10 -10
  60. data/lib/moderate/services/intake_report.rb +42 -13
  61. data/lib/moderate/services/resolve_appeal.rb +8 -2
  62. data/lib/moderate/services/resolve_flag.rb +183 -35
  63. data/lib/moderate/services/resolve_report.rb +288 -35
  64. data/lib/moderate/text_validator.rb +15 -14
  65. data/lib/moderate/version.rb +1 -1
  66. data/lib/moderate.rb +337 -17
  67. data/lib/tasks/moderate.rake +23 -0
  68. metadata +24 -14
  69. data/gemfiles/rails_7.2.gemfile +0 -36
  70. data/lib/moderate/text.rb +0 -38
  71. data/lib/moderate/word_list.rb +0 -90
data/CHANGELOG.md CHANGED
@@ -4,20 +4,281 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [1.0.0] - unreleased
7
+ ## [1.0.0] - 2026-10-19
8
8
 
9
- A complete, ground-up rewrite. `moderate` graduates from a single-purpose profanity
10
- validator (0.1.0) into a full **Trust & Safety** engine for Rails apps with user-generated
11
- content: report, block, filter, a moderation queue, appeals, and EU DSA / App Store / Google
12
- Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
9
+ The first stable release. The public API is frozen until 2.0: everything listed in the
10
+ README's "What is public" section keeps its name, arguments and meaning, and
11
+ `test/public_api_test.rb` keeps that list honest. Since 1.0.0.beta4: Ruby 3.3+ and Rails
12
+ 8.0.5.1+, attachments classified once and only once their files exist, opt-in retention with
13
+ legal holds, an appeal window that runs from confirmed delivery, and **one migration** (see
14
+ _Upgrading from 1.0.0.beta4_). Coming from an earlier beta, apply each beta's _Upgrading_
15
+ notes below in order, then these; coming from 0.x, see _Upgrading from 0.x_ at the end of
16
+ this entry.
13
17
 
14
- > **Breaking:** 1.0 keeps the gem name but is an entirely new API. The 0.x profanity
15
- > validator (`validates :field, moderate: true`) still loads for backward compatibility
16
- > (see _Upgrading from 0.x_), but everything else is new. Pin `~> 0.1` if you relied on the
17
- > old behavior and are not ready to adopt the new surface.
18
+ ### Upgrading from 1.0.0.beta4
19
+
20
+ - **One migration: run `bin/rails db:migrate`** (after `bin/rails moderate:install:migrations`
21
+ if your app holds copies of the gem's migrations, `*.moderate.rb`, as every app installed
22
+ from 1.0.0.beta4 on does; boot now fails, naming it, if you forget). `db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb`
23
+ adds `redacted_at` to `moderate_reports`, `moderate_flags` and `moderate_appeals`,
24
+ `held_until` and `covered_by_report_id` (both indexed) to `moderate_reports`, and creates
25
+ `moderate_classifications`. It
26
+ sorts after beta4's `pending_key` migration and needs no backfill. Like that one it is the
27
+ only thing that creates what it adds: `rails generate moderate:install` leaves it out of
28
+ `create_moderate_tables` and copies the migration after it, an app without copies gets it
29
+ through the engine, and its `down` drops only what it created.
30
+
31
+ - **What changes for an app upgrading from beta4** (each is detailed below):
32
+ - Ruby 3.3+ and Rails 8.0.5.1+ (Rails 8.0 hosts on json 3 need `json < 3`, as before).
33
+ - `config.signed_gid_purposes` is gone: delete the line if you set it.
34
+ - `report.resolve!` with neither `remove_content:` nor `ban_user:` is refused: dismiss
35
+ instead.
36
+ - `decision_notified_at` / `affected_user_notified_at` (and the appeal window's
37
+ extension) wait for `Moderate.delivered!` from your mailer's delivery.
38
+ - `Report#reported_content_text` is the intake snapshot.
39
+ - If your app can lift a ban, set `config.banned`.
40
+ - If you worked around the attachment upload race (CarHey's avatar), remove the
41
+ workaround.
42
+ - Attachment classification queues two `ClassifyJob`s per upload (the guard and the
43
+ immediate one); each set of files is still classified once.
18
44
 
19
45
  ### Added
20
46
 
47
+ - **A missed migration copy fails at boot.** An app holding copies of the gem's migrations
48
+ (`*.moderate.rb`) runs only those, so a release migration it didn't copy would never
49
+ run, silently. At boot (`after_initialize`) the engine compares the copies with its own
50
+ migrations by name and, when one is missing, raises `Moderate::ConfigurationError`
51
+ naming it and `bin/rails moderate:install:migrations`; under a rake task (that command,
52
+ `db:migrate`) it logs instead. **`config.migration_copies_check`** (`:raise`, `:log`,
53
+ `:off`) changes it. Apps without copies aren't checked.
54
+ - **The public API is frozen and listed.** The README's new "What is public" section names
55
+ everything that keeps its name, arguments and meaning until 2.0: the macros, `report!` /
56
+ `block!`, the reportable contract, the decision methods (`resolve!` / `dismiss!` /
57
+ `action!` / `uphold!` / `reject!`) and their services, `flag_for_review!`, `taxonomy_label`,
58
+ `reportable_field_label`, `blocked_ids_for`, `purge`, `hold` / `release_hold`,
59
+ `delivered!`, every configuration key, the view
60
+ helpers, the controller concern and its seams, the adapter contract, and the event
61
+ vocabulary with the notifier contract. The vocabulary is now code:
62
+ `Moderate::Event::NOTIFY_NAMES` and `Moderate::Event::AUDIT_NAMES`.
63
+ `test/public_api_test.rb` fails when a listed name doesn't exist, when a public
64
+ `Moderate.*` method or configuration key isn't listed, when the event lists drift from
65
+ what the gem emits, or when the README or docs/ name a `Moderate…` constant or method that
66
+ doesn't exist (it caught `Moderate::DSA_LEGAL_REASONS` in docs/compliance.md; it is
67
+ `Moderate::Report::DSA_LEGAL_REASONS`).
68
+ - **What isn't public is marked.** `@api private`: `Moderate.notify`, `.audit`, `.apply_ban`,
69
+ `.announce_ban`, `.ensure_ban_handler!`, `.run_on_block`, `.with_row_lock`,
70
+ `.register_reportable`, `.locale`; `Report::SIGNED_GLOBAL_ID_PURPOSE` /
71
+ `APPEAL_SIGNED_GLOBAL_ID_PURPOSE`, `Report#signed_reportable_gid`, `#acknowledge!`,
72
+ `#close_redress_window!`, `#block_reported_user`, `Report.locate_signed_appeal_report`;
73
+ `DeferredNotices.defer` / `.connection_for` / `Envelope`; the wordlist's compiler
74
+ internals. `Moderate::Engine::LIB_ROOT`, `MODERATE_LIB` and `ZEITWERK_IGNORED` are private
75
+ constants. Nothing tagged was removed; a host still calling one should move:
76
+ `Moderate.apply_ban` → `flag.action!(ban_user: true)` or `report.resolve!(ban_user: true)`
77
+ (which also send the statement of reasons and open the appeal window);
78
+ `to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)` and
79
+ `report.signed_reportable_gid` → `record.to_moderation_sgid` or
80
+ `moderate_report_target_path(record, field:)`.
81
+ - **docs/notifications.md lists the audit events** instead of claiming `audit` fires for every
82
+ event (`content_flagged`, for one, is notify-only: the flag row is its record).
83
+ - **Retention: `Moderate.purge(older_than: 1.year, dry_run: false)`** and the
84
+ `moderate:purge` rake task (`OLDER_THAN_DAYS`, `DRY_RUN`), opt-in. They redact the personal
85
+ data in reports, notices, flags and appeals whose decision is final (closed, decided more
86
+ than `older_than` ago, appeal window over, no open appeal): messages, evidence snapshots,
87
+ reported URLs, notifier and appellant contact details, flag excerpts and context. Every row
88
+ and every decision stays, so the statement of reasons, a repeat-misuse history and the
89
+ transparency counts are unchanged; blocks and pending records are never touched;
90
+ `older_than` below the six-month appeal window raises. Idempotent, batched, audited as
91
+ `:retention_purged`. `docs/compliance.md` gains a retention section: what the gem stores,
92
+ that nothing is purged by default, and what the DSA and the GDPR expect. A purged row is
93
+ stamped `redacted_at`, and the purge selects on that column, never on the content (a
94
+ message that literally read `"[redacted]"` would otherwise have kept its snapshot, URLs,
95
+ contacts and reporter, and `dry_run` under-counted it). The returned counts are
96
+ `:reports`, `:flags`, `:appeals`, `:classifications`, `:claims_kept` and `:held`.
97
+ `1.year` is documented as a default the host sets per its own purposes.
98
+ - **Legal holds: `Moderate.hold(report, until:, by: nil, reason: nil)` /
99
+ `Moderate.release_hold(report)`** and `Report#held?`. A held report, its appeals and the
100
+ flags on its content are left out of the purge until the date (an out-of-court dispute
101
+ under DSA Art. 21, a court case, an authority's request); `dry_run` and the rake task
102
+ report held rows separately; both are audited (`:legal_hold_placed`,
103
+ `:legal_hold_released`).
104
+ - **RuboCop runs in CI** (a `rubocop` job next to the test matrix) and the codebase has zero
105
+ offenses. `.rubocop.yml` now describes the code as it is: size metrics off (the gem
106
+ explains itself in long, commented methods), complexity metrics capped where the code is,
107
+ 140-column lines, continuation lines indented one step, and the ecosystem's `has_*` macro
108
+ names allowed. The rest was fixed: safe autocorrections plus a few by hand (`rescue
109
+ StandardError`, double-quoted strings, documentation comments on the small classes,
110
+ `FileUtils.rm_f`). New cops are enabled.
111
+
112
+ ### Fixed
113
+
114
+ - **`Report#reported_content_text` is the evidence snapshot**, as its documentation always
115
+ said: the reported field's text when the report was filed, surviving edits and deletion
116
+ (nil once a purge redacts the report). It read the live content.
117
+ - **Refusals, validation messages and the admin concern's flash messages ship in English
118
+ and Spanish** (`moderate.errors.*`, `moderate.moderation.*`); they existed only as inline
119
+ English defaults. A test keeps every such key covered in both files. (The notice, appeal
120
+ and transparency pages' own copy still has inline English defaults, overridable under
121
+ `moderate.notices.*`, `moderate.appeals.*`, `moderate.transparency.*`.)
122
+ - **docs/madmin.md listed a `.resolved` scope** that doesn't exist; the scopes are
123
+ `.pending`, `.actioned`, `.dismissed` and `.recent_first`.
124
+ - **A second report on content an earlier decision already removed closes against it.**
125
+ Flags had "already dealt with"; reports had no truthful way to close: removing again
126
+ removed nothing and was refused. A decision that removes content now reads, under the
127
+ content's lock and right before removing, the text it is about to remove (recorded as
128
+ `resolution_actions["removed_text_digest"]`), and stamps the OTHER reports pending on
129
+ that content and field whose intake snapshot holds exactly that text, once the removal is
130
+ verified (below),
131
+ (`covered_by_report_id`, readable as `Report#covered_by`); with no readable text it
132
+ stamps nobody. A stamped report resolved with `remove_content: true` closes against that
133
+ decision while the decision stands (no appeal against it upheld: an overturned removal
134
+ covers nothing, though the stamp stays as history) and the content is gone now (read
135
+ fresh under the lock; deleted content counts), and records
136
+ `resolution_actions["content_already_removed_by"]`. No clocks are compared: a report that
137
+ wasn't pending when the content was removed (a repost, a removal that didn't take, a late
138
+ or backdated report) was never stamped, so a report filed after a removal is never
139
+ credited to it; the normal removal or refusal ("Nothing was removed") applies. A report
140
+ filed during the removal, or one whose snapshot holds older text than what was removed,
141
+ is stamped only if its snapshot holds the removed text. Nothing is removed, audited or
142
+ announced a second time, the author gets no second statement of
143
+ reasons (unless this decision also bans), and the reporter's `report_decision` carries
144
+ `content_already_removed_by` and says "already removed by the decision on Report #X".
145
+ - **A removal is verified, not taken on trust.** When the reportable's
146
+ `remove_reported_field!` returns true, the field is read again under the same lock; unless
147
+ it reads as gone (through `moderation_snapshot` and the field itself, or `deleted?`) the
148
+ decision is refused as "Nothing was removed", nobody is stamped and no digest recorded. A
149
+ host method that rewrote the field instead of removing it used to be recorded as a
150
+ removal. The seam's contract is now written in the README: remove the field's content as
151
+ it is when called, persist it, return whether anything was removed, and never rewrite the
152
+ field first.
153
+ - **A report whose content was deleted can be decided.** The intake check that a report
154
+ without content names a URL ran on every save, so once the content was deleted (its
155
+ pointer nullified) no decision on the report could be saved. It now runs at intake.
156
+ - **A report resolved with neither removal nor ban is refused.** It recorded
157
+ `decision_visibility: "other_restriction"` and sent the author a statement of reasons
158
+ for a restriction nobody imposed. `resolve!` restricts something (`remove_content:`,
159
+ `ban_user:`, or both); with neither it now raises `ActiveRecord::RecordInvalid` with
160
+ `moderate.errors.resolve_needs_action` (en/es: "… To close it without a restriction,
161
+ dismiss it."), and the report stays open. **Behavior change:** a host that resolved
162
+ reports with only a note (a "warning") should `dismiss!` them, or apply its own measure
163
+ and dismiss. `other_restriction` stays in the decision taxonomy for labels.
164
+ - **A ban after a host-side unban is applied again.** One ban per person per episode: a
165
+ second ban decision in the same episode was recorded as covered by the first
166
+ (`ban_already_applied_by`), so after the host lifted the ban the `ban_handler` wasn't
167
+ called and the statement of reasons said "account suspended" for an account nothing had
168
+ suspended. **`config.banned = ->(user) { … }`** (optional, documented beside
169
+ `ban_handler`) says whether the person is banned now: when it returns false the earlier
170
+ ban no longer counts and the handler runs again. Unset, the earlier ban counts, as before;
171
+ hosts that can unban should set it.
172
+ Both `config.banned` and the `ban_handler` get the person as read again, with a locking
173
+ read, once the decision holds their lock: two ban decisions on one person at once ban
174
+ once (the second records `ban_already_applied_by`), even inside a host transaction that
175
+ read the person earlier (on MySQL that read pins an older snapshot).
176
+ - **`validates :field, moderate: true` works again.** The README promised the 0.x one-liner
177
+ still worked; it raised `Unknown validator: 'ModerateValidator'`, because its file was
178
+ never loaded (and had it been, it downloaded a word list over HTTP and read a
179
+ configuration key 1.0 doesn't have). It is now what the README says: the built-in
180
+ `:wordlist` in `:block` mode, adding the same `:objectionable_content` error (or your
181
+ `message:`). The unreachable 0.x `Moderate::Text` and `Moderate::WordList` are deleted.
182
+ - **A field rejected by `:block` reads as copy.** `errors.add(field, :objectionable_content)`
183
+ had no translation, so a person saw "Body Translation missing. Options considered
184
+ were: …". The gem ships `errors.messages.objectionable_content` in English ("contains
185
+ language that isn't allowed") and Spanish; override it per model and attribute the usual
186
+ Active Record way.
187
+ - **docs/notifications.md's statement-of-reasons mailer had no working appeal link.** It
188
+ read `event.payload[:appeal_url]`, which the gem has never set, so a copied mailer
189
+ rendered an "Appeal this decision" button to nowhere (Art. 17(3)(f) requires the redress
190
+ path). It now builds the link the way a working host does:
191
+ `moderate.new_appeal_url(token: event.subject.signed_appeal_gid, source: "affected_user")`.
192
+ - **An attachment is classified once its file is uploaded, never before.** Active Storage
193
+ uploads a new attachment in its own `after_commit`, and commit callbacks run in the order
194
+ they were defined, so on a model that calls `moderates` above `has_one_attached` the
195
+ filter ran first: a synchronous adapter classified a blob whose file wasn't on the
196
+ service yet, and an async adapter's `ClassifyJob` could run before the upload finished.
197
+ The filter now watches Active Storage's upload itself, from `before_save` (so in either
198
+ declaration order), and classifies when it returns: inline for a synchronous adapter, by
199
+ an immediate job for an async one. Works for `has_one_attached` and `has_many_attached`.
200
+ `ClassifyJob` also checks that every blob behind the value exists on its service:
201
+ if one doesn't (a direct upload still in flight, a lagging replica) it re-enqueues itself
202
+ after 5, 10, 15 and 20 seconds (`UPLOAD_WAIT`, `UPLOAD_ATTEMPTS`), then gives up with a
203
+ `classification_skipped` audit event. Nothing classifies a file that isn't there: the
204
+ inline (synchronous adapter) path checks too, and hands a missing file to the job; a
205
+ direct upload attached before its file arrives (whose Active Storage `upload` is a no-op)
206
+ waits the same way. A failed upload is never silent: just before Active Storage uploads,
207
+ the filter queues a guard `ClassifyJob` for `UPLOAD_WAIT` seconds later, so an upload that
208
+ raises (or a process that dies) still ends in a classification or a `classification_skipped`
209
+ audit. Each set of files is classified once: the job takes `blob_ids:` and
210
+ stops if the field holds other files by then, and whoever classifies first records it,
211
+ so the guard finds that and stops.
212
+ The inline path and the job claim the files under the content record's row lock (content
213
+ first, as everywhere), call the adapter outside any lock, then re-take the lock to file
214
+ the flag, mark the files classified and clear the claim, but only if the field still
215
+ holds the same files and the claim is still theirs (else the result is discarded: the
216
+ new files have their own classification). The immediate job and the guard can't both
217
+ classify, and a slow provider never blocks writes to the content's row. A claim expires
218
+ after **`config.classification_claim_ttl`** (10 minutes): a job that finds a live claim
219
+ looks once more at its expiry, so a worker that died mid-call is taken over. The mark is
220
+ a row of the gem's own **`moderate_classifications`** table (record, field, a digest of the
221
+ ordered blob ids, the claim, `classified_at`, the adapter and whether it flagged; UNIQUE
222
+ on record + field + digest), written only under the content lock. A changed set (a file
223
+ added or removed) or the same blob on another record is another row, classified again;
224
+ nothing of ours is written to `blob.metadata`, so other records sharing a blob and Active
225
+ Storage's analyzer can't overwrite a claim. `Moderate.purge` deletes rows last touched
226
+ before its cutoff (`:classifications` in its counts), never one under a live claim
227
+ (`:claims_kept`). `classification_claim_ttl` must be more than 0 and at most a day, or
228
+ boot raises. An adapter that raises leaves its claim until it expires and schedules no
229
+ retry itself; the guard job, a job's recheck at expiry, a new save or the job backend's
230
+ own retry takes it over. Under **`mode: :block`**, an attachment whose stored blob isn't
231
+ on the service yet (a direct upload in flight) fails validation with
232
+ `errors.messages.upload_not_finished` ("hasn't finished uploading yet", en/es) instead of
233
+ being classified without its file.
234
+ **If you worked around this** (CarHey's avatar):
235
+ 1. delete the `moderation_field_changed_for_commit?` override that returns `false` for
236
+ the attachment field;
237
+ 2. delete your own `Moderate::ClassifyJob.perform_later(self, "avatar")` after_commit
238
+ and the `before_save` that remembered the attachment change, or the field is
239
+ classified twice;
240
+ 3. the order of `moderates` and `has_one_attached` no longer matters, so the "keep this
241
+ below the attachment" comments can go. An adapter that retries on
242
+ `ActiveStorage::FileNotFoundError` may keep doing so; the job's own wait makes it rare.
243
+ - **The appeal window runs from when the person is informed** (DSA Art. 20(2)), not from
244
+ the decision, and only a confirmed delivery counts. A decision stamps
245
+ `appeal_deadline_at` six months out, a minimum (its notice's `appeal_deadline_at` is that
246
+ date: "at least until"). **`Moderate.delivered!(report, :decision | :affected_user, at:)`**,
247
+ which your notifier calls when the message actually went out (Action Mailer's
248
+ `after_deliver`; docs/notifications.md), stamps `decision_notified_at` /
249
+ `affected_user_notified_at` once and moves the deadline to six months after it, never
250
+ earlier; later calls change nothing. **Behavior change:** those two timestamps are no
251
+ longer stamped because the notify hook returned something truthy (a queued delivery job
252
+ is not a delivery), so a host that reads them must call `Moderate.delivered!`.
253
+ - **An appeal filed in time can be decided after the appeal window closes.** The six-month
254
+ check (`report_must_still_be_appealable`) ran on every save, so `appeal.uphold!` /
255
+ `reject!` on a complaint filed in month five and decided in month seven failed with "the
256
+ appeal window for this decision has closed". Art. 20 bounds when a complaint may be
257
+ filed, not when it is decided; the check now runs when the appeal is created.
258
+
259
+ ### Removed
260
+
261
+ - **`config.signed_gid_purposes`.** Nothing ever read it: each signed token's purpose is
262
+ fixed by the flow that mints it. A host initializer that sets it now raises
263
+ `NoMethodError` at boot; delete the line (the generated initializer only had it
264
+ commented out).
265
+ - **Rails 7.2 and older, and Ruby 3.2, are no longer supported** (all end of life upstream:
266
+ Rails 7.2's security support ended in August 2026). The gemspec now requires Ruby 3.3+ and
267
+ `activerecord` / `activesupport` / `railties` `>= 8.0.5.1, < 9.0` (8.0.5.1 is the first 8.0
268
+ release with every current Rails security advisory fixed). CI runs Ruby 3.3, 3.4 and 4.0
269
+ against Rails 8.0 and 8.1 on SQLite, plus PostgreSQL and MySQL, and a `bundler-audit` job
270
+ checks every lock it tests with. The Rails 7.1 and 7.2 appraisals are gone; the `json < 3`
271
+ pin moved to the 8.0 appraisal, because Rails 8.0 still passes `quirks_mode:` to
272
+ `JSON.generate`, which json 3 rejects. A host on 8.0 needs the same pin, with or without
273
+ `moderate`.
274
+
275
+ ### The 1.0 feature set (since 0.1.0)
276
+
277
+ A complete, ground-up rewrite. `moderate` graduates from a single-purpose profanity validator
278
+ (0.1.0) into a full **Trust & Safety** engine for Rails apps with user-generated content:
279
+ report, block, filter, a moderation queue, appeals, and EU DSA / App Store / Google Play
280
+ **aligned** primitives (first cut shipped as `1.0.0.beta1`).
281
+
21
282
  - **Reporting.** `Moderate::Report` plus the `has_reportable_content :fields` macro and
22
283
  `Actor#report!(reportable, category:, details:)`. Reports and DSA notices share one model
23
284
  and one queue (`intake_kind: "community" | "dsa"`).
@@ -40,17 +301,19 @@ Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
40
301
  at any path, **statement of reasons** (Art. 17), internal **appeals** (Art. 20), and
41
302
  **transparency** counters (Art. 24). The notice form prefills from query params + the signed-in
42
303
  user, locks auto-filled identity fields, and auto-detects `rails_cloudflare_turnstile`.
43
- - **Hooks (all no-op by default).** `config.audit`, `config.notify` (returns a delivery boolean
44
- used to gate `decision_notified_at`), `config.on_block`, `config.ban_handler`, the
304
+ - **Hooks (all no-op by default).** `config.audit`, `config.notify` (its truthy return marks
305
+ a DSA notice's receipt as sent; decision notices count as delivered only through
306
+ `Moderate.delivered!`), `config.on_block`, `config.ban_handler`, the
45
307
  host-overridable `config.report_categories`, and `config.notice_human_verification_skip_if` /
46
308
  `config.appeal_human_verification_skip_if` for native-app bot-gate carve-outs.
47
- - **Optional integrations**, all auto-detected at runtime via `defined?`/`respond_to?` and never
48
- hard dependencies: madmin, goodmail, telegrama, noticed, rails_cloudflare_turnstile.
309
+ - **Optional integrations**, never hard dependencies: madmin, goodmail, telegrama and noticed are
310
+ wired through the hooks above (recipes in docs/), and rails_cloudflare_turnstile is
311
+ detected at runtime by the public forms.
49
312
  - **Install tooling.** `rails generate moderate:install` writes a documented initializer and an
50
- adaptive migration (uuid/bigint primary keys, jsonb/json/MySQL JSON columns); `moderate:views`
51
- ejects the notice form for customization.
313
+ adaptive migration (uuid/bigint primary keys, jsonb/json/MySQL JSON columns), then copies the
314
+ gem's own migrations after it; `moderate:views` ejects the notice form for customization.
52
315
 
53
- ### Changed
316
+ Design choices that differ from 0.x:
54
317
 
55
318
  - Taxonomies (report categories, DSA legal reasons, country codes) are now frozen model
56
319
  constants with inclusion validations instead of DB `CHECK` constraints — adding or
@@ -63,10 +326,387 @@ Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
63
326
 
64
327
  ### Upgrading from 0.x
65
328
 
66
- - The 0.x profanity validator still loads: `validates :field, moderate: true` continues to work
67
- via compatibility shims, so existing apps keep validating. To adopt 1.0, add
68
- `has_reporting_and_blocking` to your user model and `has_reportable_content` / `moderates` to your
69
- content models, run `rails generate moderate:install`, and migrate.
329
+ > **Breaking:** 1.0 keeps the gem name but is an entirely new API. Pin `~> 0.1` if you relied
330
+ > on the old behavior and are not ready to adopt the new surface.
331
+
332
+ - `validates :field, moderate: true` still works: it is now the built-in `:wordlist` adapter
333
+ in `:block` mode (the same check as `moderates :field, with: :wordlist, mode: :block`), so
334
+ existing apps keep validating. To adopt the rest, add `has_reporting_and_blocking` to your
335
+ user model and `has_reportable_content` / `moderates` to your content models, run
336
+ `rails generate moderate:install`, and migrate.
337
+
338
+ ## [1.0.0.beta4] - 2026-10-18
339
+
340
+ Concurrency fixes, and **one migration**: run `bin/rails db:migrate` after upgrading (see
341
+ _Upgrading from 1.0.0.beta3_ below). No API change.
342
+
343
+ ### Fixed
344
+
345
+ - **MySQL double enforcement under REPEATABLE READ.** Two moderators enforcing flags on the
346
+ same content, or banning the same person from two flags at once, could remove the content
347
+ twice or call the `ban_handler` twice on MySQL. A plain `SELECT` there returns the
348
+ transaction's consistent snapshot, which may predate the content's or the person's row
349
+ lock, so the second worker never saw the decision it had waited for. Every check that
350
+ decides "already done" under a lock (the covering decision and the open report a flag
351
+ escalates to, whether the content is gone, the per-person prior ban) is now a locking read
352
+ (`SELECT … FOR UPDATE`), identical on every database; the redundant plain reloads after
353
+ `with_lock` are gone. A flag escalation that can ban takes the person's row lock right
354
+ after the content's, before it reads the reports table, so two bans of the same person
355
+ serialize before either takes a gap lock (MySQL's locking reads take them, and two holders
356
+ that each insert a report deadlock). PostgreSQL and SQLite were not affected.
357
+ - **One lock order everywhere: flag, then content, then person, then report.**
358
+ `Flag.flag_for_review!` locked the content and then the waiting flag, the reverse of a
359
+ flag decision (the flag, then the content), so a review and an enforcement of the same
360
+ content could deadlock on any database. It now takes no content lock at all (see the next
361
+ entry); the order is documented once, on `Moderate.with_row_lock`.
362
+ - **At most one pending flag per content and field, enforced by the database.** Two
363
+ "Flag for review" filings at once could create two pending flags for the same field: a
364
+ plain "is one already waiting?" read can't see another transaction's flag under MySQL's
365
+ REPEATABLE READ once the caller's transaction has read anything. `moderate_flags` gets a
366
+ `pending_key` (`"<flaggable_type>/<flaggable_id>/<field>"` while pending, NULL once closed;
367
+ the model keeps it in sync on every save, so closing frees it and reopening takes it back)
368
+ with a unique index. The key compares as bytes everywhere: on MySQL the column is
369
+ `utf8mb4_bin`, since the default collation ignores case and would make content types that
370
+ differ only by case (`Post`, `POST`) share one key. `utf8mb4_bin` still ignores trailing
371
+ spaces (MySQL's PAD SPACE), so `Flag#field` is now stripped wherever it is assigned: a key
372
+ never differs by them. `Flag.flag!` and `Flag.flag_for_review!` insert and, on the
373
+ collision, return the flag already waiting, read with a locking read (the latest committed row on
374
+ every database); no plain read decides anything, and `flag_for_review!` locks only the
375
+ waiting flag, only on a collision. On MySQL, locking a pending flag (`with_lock`, `lock!`)
376
+ takes its `pending_key` index entry before its row, the order a colliding insert takes
377
+ them in, so a filing and a decision on the same field don't deadlock. The one deadlock
378
+ InnoDB can still pick (an insert's duplicate check queued on the gap a closing flag's index
379
+ entry moves into) is retried by `Flag.flag!` when it is the whole transaction, and raised
380
+ as `ActiveRecord::Deadlocked` inside a caller's transaction, which InnoDB rolled back.
381
+ `Flag.flag!` tries three times, then raises the last failure as it was:
382
+ `ActiveRecord::Deadlocked` after three deadlocks, `ActiveRecord::RecordNotUnique` only when
383
+ each collision's waiting flag was decided before it could be joined.
384
+ - **The install migration on Trilogy.** `moderate:install`'s migration now treats Rails'
385
+ Trilogy adapter as MySQL, as it already did mysql2 (MySQL refuses defaults on JSON columns).
386
+ - **Removal-only enforcement no longer locks the person's row.** Only a decision that can
387
+ ban takes the account's lock, so removing one message never waits on (or holds up) other
388
+ work on the author's account.
389
+
390
+ ### Changed
391
+
392
+ - **While a flag waits on a field, another flag on that field joins it.** `Flag.flag!` (the
393
+ filters' and `ClassifyJob`'s builder too) returns the waiting flag instead of filing a
394
+ second one (`flag.previously_new_record?` is false; no second `content_flagged`). A field
395
+ has one item in the queue; once it's decided, the next flag files anew. Code that relied on
396
+ several pending flags per field, or that closes flags with `update_column` / `update_all`,
397
+ must set `pending_key` itself (NULL when not pending).
398
+
399
+ ### Upgrading from 1.0.0.beta3
400
+
401
+ 1. `bin/rails db:migrate`. The engine adds the gem's `db/migrate` to your app's migration
402
+ paths, so `AddPendingKeyToModerateFlags` runs with your own migrations (don't also copy it
403
+ with `moderate:install:migrations`; if you already did, the copy runs instead). It adds the
404
+ column and the unique index, and backfills: on each content and field, the newest pending
405
+ flag takes the key; older duplicate pending flags from before this release stay pending
406
+ without one (close them when you can). Before writing any key it has the database group
407
+ the keys under the column's own collation, and if it would treat two as one (on MySQL,
408
+ a legacy flag whose field ends in a space against one without) it stops with a message
409
+ naming each key and its flags, rather than failing on an UPDATE or the index; close all
410
+ but one of each, or fix the field, and run it again. On MySQL it refuses an existing
411
+ `pending_key` column that ignores case, giving the `ALTER TABLE` to run. It is the only
412
+ migration that creates `pending_key`, in every app, so `down` drops the index and the
413
+ column and leaves the tables `create_moderate_tables` made as they were.
414
+
415
+ ### Changed (installing)
416
+
417
+ - **`moderate:install` copies the gem's migrations too.** A fresh install gets
418
+ `create_moderate_tables` (the 1.0.0.beta3 table shape, no `pending_key`) and then, in
419
+ order, a copy of each of the gem's own migrations, named like
420
+ `bin/rails moderate:install:migrations` names them (`…_add_pending_key_to_moderate_flags.moderate.rb`).
421
+ An app holding such copies runs them, and the engine leaves the gem's `db/migrate` out of
422
+ its paths; from then on, after upgrading, run `bin/rails moderate:install:migrations` and
423
+ migrate. An app installed without copies (as with beta3) keeps getting the gem's
424
+ migrations through the engine.
425
+
426
+ ## [1.0.0.beta3] - 2026-10-18
427
+
428
+ ### Added
429
+
430
+ - **Human labels for reportable fields.** `has_reportable_content :body, :files, labels:
431
+ { body: "Chat message", files: "Chat photo" }` (and `reportable_fields …, labels:`) names
432
+ each field once, next to its declaration, so moderators and affected users stop reading raw
433
+ column names («Body») for what was reported. Resolution: the declared label (a String, or a
434
+ lambda evaluated at read time) → i18n `moderate.reportable_fields.<model>.<field>` (walking
435
+ reportable STI parents) → `moderate.reportable_fields.<field>` → `field.humanize`. Read it
436
+ with `Moderate.reportable_field_label(record_or_class_or_type, field)`,
437
+ `Report#reported_field_label`, `Flag#field_label`, or `record.reportable_field_label(field)`;
438
+ labels resolve from the stored type, so they survive the content being deleted. A label key
439
+ for an undeclared field raises. Hosts that kept their own field → label map can delete it.
440
+ - **Labels everywhere the gem names a field.** The public appeal page shows the reported
441
+ content's label; the statement-of-reasons payload (`affected_user_decision`, `report_decision`)
442
+ carries `reported_field` + `reported_field_label`; `report_received` carries both and appends
443
+ the label to its summary; `content_flagged` and the `flag_decision` audit carry `field_label`,
444
+ and the `content_flagged` summary prints the label instead of `Type#field`.
445
+ - **Default locale files** (`config/locales/en.yml`, `es.yml`): the appeal page's new string and
446
+ generic labels for common field names (`body` → "Text"/"Texto", `avatar` → "Profile
447
+ photo"/"Foto de perfil", …).
448
+ - **Labels for every value list the gem defines, in English and Spanish.**
449
+ `Moderate.taxonomy_label(kind, value)` names a community category, a DSA legal reason, an
450
+ EU member state, a content type, a resolution basis, a decision, an intake kind, a report /
451
+ flag / appeal status, a flag source or mode, or an appeal source in the current locale, from
452
+ `moderate.<scope>.<value>` (scopes in `Moderate::TAXONOMY_LABEL_SCOPES`), falling back to
453
+ the value humanized so a host's own category or adapter name still renders. The gem's
454
+ `config/locales/{en,es}.yml` now carry every value, and a host's translation wins. The
455
+ public notice form, which always read `moderate.legal_reasons/content_types/member_states`,
456
+ now shows real copy in both languages. **`Moderate::Report.category_options`** gives a
457
+ report form's select its `[label, value]` pairs, honoring `config.report_categories`. Hosts
458
+ that translated the regulator's vocabulary themselves can drop their copies for en/es.
459
+ - **An in-app report's details are optional.** A community report with a blank message now
460
+ files with the category's label (in the reporter's language) as its message, instead of
461
+ failing "Message can't be blank": the category a person picked is the reason. A DSA notice
462
+ still needs its own explanation (Art. 16(2)(a)). Hosts that filled the message with the
463
+ category label themselves, or whose forms said "optional" while the gem refused it, can
464
+ stop.
465
+ - **"Report and block": `report!(…, block: true)`** blocks the person responsible for the
466
+ content in the same transaction as the report (both or neither; the receipt goes out
467
+ after), through the one block path (idempotent, audited, `on_block`, `user_blocked`). `Report#block_reported_user`, which has existed since beta1
468
+ and which nothing read, is what it sets. Nothing is blocked when the content has no person
469
+ behind it.
470
+ - **Reports know what kind of content they're about.** A community report with no
471
+ `content_type` takes the reportable's `moderation_content_type` (with or without the field
472
+ argument, since chats' takes none), stored if it is one of `Report::CONTENT_TYPES`, else
473
+ `"other"`, so a host's own word never fails an intake. `Moderate::Reportable` defines it
474
+ (nil) and `Moderate::Actor` answers `"user_profile"`. Hosts that mapped their models to
475
+ content types in the report controller can delete the map.
476
+ - **`Reportable#to_moderation_sgid`** and the view helper
477
+ **`moderate_report_target_path(record, field:)`**: the signed report target and the report
478
+ form's path, public and non-expiring, for markup rendered once for many viewers (a chat
479
+ bubble broadcast to everyone in the thread, a fragment cache), where
480
+ `moderate_report_link`'s per-viewer check can't run. The helper always probed for
481
+ `to_moderation_sgid`; now it exists. Hosts building
482
+ `to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)` by hand can use these
483
+ (without `expires_in: nil`, that token changed on every render and died after a month).
484
+ - **The `content_removed` event is emitted.** It was in the documented vocabulary from
485
+ beta1 and nothing sent it. A decision whose `remove_reported_field!` removed something now
486
+ audits `content_removed` inside the decision's transaction and announces it with the
487
+ decision notices, to the content's person, with `reported_field`, its label and the
488
+ moderator's reason. (`notice_received`, which the DSA form has always sent, joins the
489
+ vocabulary lists in the README, the configuration guide and the initializer.)
490
+ - **`examples/regexp_pattern_adapter.rb`**: a reference adapter for what a word list can't
491
+ express (phone numbers, links, "contact me on…"), each pattern filed under the category it
492
+ means, synchronous so `:block` works too, the matched fragments in `raw[:matches]`. The
493
+ idea is @killondark's (#1, written against the 0.x validator).
494
+ - **Enforce from a flag.** `flag.action!(by:, note:, remove_content: true)` and
495
+ `flag.action!(by:, note:, ban_user: true)` (any combination, plus `resolution_basis:`)
496
+ escalate to a report and resolve it: the newest open report on the same content and field
497
+ if a person already filed one, else a system report (no reporter, so no receipt and no
498
+ reporter-facing decision). Content is removed through the reportable's
499
+ `remove_reported_field!`, the ban goes through your `ban_handler`, and the affected user
500
+ gets the statement of reasons and the appeal window a restriction requires. It runs under
501
+ the flag's row lock and then the content's, so two moderators enforcing the same flag, or
502
+ two flags on the same content, enforce once: the second closes against the decision the
503
+ first took (unless it asks for more, say a ban after a removal). A recorded removal only
504
+ counts if the content is gone now, so an older decision that said "removed" without
505
+ removing anything doesn't stand in for a real one. A system report may name the flag's
506
+ field even when people can't report that field (a filtered image) — an exception only
507
+ the escalation sets, on a report with no reporter, never on a DSA notice — and
508
+ enforcing a classifier's flag through a person's earlier report adds the classifier to
509
+ that report's automated-means disclosure. `flag.report` is the report it went through.
510
+ Hosts that hand-wrote this bridge (find-or-create a report, resolve it, then close the
511
+ flag) can delete it. Without those options `action!` is the triage close it was.
512
+ - **`Moderate::Flag.flag_for_review!(record, field:, by:, note: nil)`**: an administrator
513
+ filing content for review by hand (the "Flag for review" button on a transcript line). It
514
+ returns the pending flag on that field if one already waits, else files a
515
+ `source: "manual"` flag owned by the content's person, with the evidence snapshot as
516
+ excerpt and `context` naming who filed it; `flag.previously_new_record?` says which. The
517
+ check and the insert run under the record's row lock, so two administrators pressing it
518
+ together file one flag. Audited as `:flag_filed`. Hosts that hand-assembled the nine
519
+ `Flag.flag!` arguments (and, without a lock, could file two) can call this instead.
520
+ - **`Flag#resolve!`**, an alias of `action!`: reports and flags now share one verb and one
521
+ keyword contract, which is what `Moderate::Moderation#resolve` and `docs/madmin.md`
522
+ always assumed.
523
+
524
+ ### Changed
525
+
526
+ - **`Flag#action!` / `#dismiss!` go through `Moderate::Services::ResolveFlag`.** The beta2
527
+ model methods were a second closing path with no row lock and no `flag_decision` audit;
528
+ now there is one. Re-closing an already decided flag returns it unchanged (as the service
529
+ always did) instead of overwriting the first decision.
530
+ - **`ResolveReport.new(report, by:, deliver: false)`** holds the decision notices back for a
531
+ caller that decides inside a lock of its own; `#deliver_decision_notices` sends them.
532
+ - **Notices follow Active Record's after_commit rules, on every supported Rails.** Report,
533
+ flag and appeal decisions (with their `user_banned` and `content_removed`), the in-app
534
+ report receipt, and `user_blocked` / `user_unblocked` are owned by the transaction they
535
+ were queued in (`Moderate::DeferredNotices`, `record.after_commit_notice { … }`): each is
536
+ enrolled in the current transaction as a transaction record, exactly as a saved record
537
+ is, so the transaction sends it on commit and drops it on rollback or when an exception
538
+ ends it. Nested transactions and savepoints under a joinable parent send at the
539
+ outermost commit; a rolled-back savepoint drops only its own; under a parent that isn't
540
+ joinable (including the wrapper of Rails' transactional tests) it's sent at the
541
+ savepoint's release, when after_commit runs too; with no transaction open, at once.
542
+ Under multiple databases or shards, the notice joins the transaction of the connection
543
+ it's queued on: `record.after_commit_notice(on: connection) { … }`, `on:` defaulting to
544
+ the record class's connection at call time. Queue it inside the transaction that saved
545
+ the record, on that transaction's connection. The gem's services do: each captures its
546
+ model's connection at the top of its transaction, before any save, and passes it to
547
+ every notice it queues, so a host callback that switches databases or shards mid-save
548
+ can't move them. A host that calls `after_commit_notice` itself passes `on:` when it uses
549
+ `connected_to`.
550
+ Nothing lives on a record instance or on the thread. A host that decides, reports or
551
+ blocks inside a transaction of its own and rolls it back tells nobody; Rails 7.1, 7.2 and
552
+ 8.x behave the same. `Moderate.apply_ban(…, announce: false)` +
553
+ `Moderate.announce_ban` split the ban's notice from the ban for the same reason. (The
554
+ public notice and appeal forms' own receipts are unchanged.)
555
+ - **One lock order for enforcement: flag, then content, then the person (for a ban), then
556
+ the report.** Row locks are taken by query (`Moderate.with_row_lock`), so concurrent
557
+ decisions on the same content, or bans of the same person from different content,
558
+ serialize instead of enforcing twice or deadlocking.
559
+ - **One ban per person per episode.** A decision asked to ban someone who a decision on
560
+ any content or field has already banned since this conduct was raised (the report's
561
+ creation; the flag's, for an escalation) doesn't call the `ban_handler` again: it records
562
+ `resolution_actions["ban_already_applied_by"]` with that decision's id. An older ban
563
+ doesn't count (the person may have been unbanned since).
564
+ - **A manual flag is not automated means.** A report's automated-processing disclosure
565
+ (DSA Art. 17(3)(c)) no longer counts `source: "manual"` flags on the same content.
566
+
567
+ - **Removing content that isn't removable refuses the decision.** `remove_content: true`
568
+ when the reportable's `remove_reported_field!` removes nothing (the default for a
569
+ reportable that defines no removal, a field that isn't removable, content already gone),
570
+ when the content was deleted (the report's reportable is nil), or when the reportable has
571
+ no removal method at all, now raises `RecordInvalid` ("Nothing was removed…", `moderate.errors.nothing_removed`)
572
+ and rolls the decision back, a ban with it. Before, the report was closed with
573
+ `decision_visibility: "content_removed"` and the affected person was told their content
574
+ was removed while it was still up. **Behavior change:** offer "remove content" only where
575
+ `removable_reported_field?` says it can happen.
576
+ - **A ban needs a `ban_handler`.** With none configured, `ban_user: true` (on a report or a
577
+ flag) and `Moderate.apply_ban` raise `Moderate::ConfigurationError` before anything
578
+ changes. Before, the default no-op handler let the decision record
579
+ `decision_visibility: "account_suspended"`, audit and announce `user_banned`, and send the
580
+ person a statement of reasons saying their account was suspended, while nothing had
581
+ suspended it: a false Art. 17 statement. `config.ban_handler = nil` restores the default;
582
+ a non-callable raises `ArgumentError`; `config.ban_handler_configured?` tells. Decisions
583
+ without a ban are unaffected. **Behavior change** for hosts that never wired one and
584
+ relied on the no-op.
585
+ - **An in-app report is refused when its reporter may not report the content.** A community
586
+ report filed by a signed-in person now validates the reportable's own `report_visible_to?`
587
+ (never your own content; for a chats message, only a conversation you're in), with an
588
+ error on `:reportable` ("isn't something you can report",
589
+ `moderate.errors.not_reportable_by_reporter`). The report link always hid itself on that
590
+ rule, and chats' docs said moderate re-checks it server-side, but nothing did: a signed
591
+ target replayed or pasted from elsewhere reached the model unchecked. DSA notices and
592
+ system reports (no reporter) are not affected. **Behavior change:** a host that files
593
+ community reports on behalf of people who couldn't see the content now gets a validation
594
+ error; use a manual flag (`Flag.flag_for_review!`) for an administrator's own concern.
595
+
596
+ ### Fixed
597
+
598
+ - **`moderate_report_target_path` (and `moderate_report_link`) find the host's report route
599
+ from inside another engine's views**, through `main_app`. Rendered in a chats slot, the
600
+ helper fell back to the hard-coded `/reports/new`.
601
+ - **The transparency page labels its counters from the gem's own keys.** Two of its cards
602
+ read `moderation.legal_reasons.*` / `moderation.resolution_bases.*`, a namespace from the
603
+ app the gem was extracted from that no other part of the gem uses; the rest printed raw
604
+ values humanized. Every card now goes through `Moderate.taxonomy_label`.
605
+ - **`Moderate.blocked_ids_for(nil)` returns the same type as every other call**: an empty
606
+ relation selecting user ids, not `[]`. Composing the two shapes was a real bug in a host
607
+ (`relation | array` yields User records, and `include?(id)` silently missed). Call `.ids`
608
+ for an Array.
609
+ - **`Report#pending?` and `Appeal#pending?`** exist, twins of the `pending` scopes all three
610
+ queues share (`docs/madmin.md` called them and they didn't exist).
611
+ - **`Moderate::Moderation#resolve` works on flags.** It called `record.resolve!`, which
612
+ `Moderate::Flag` didn't have, so a host following the concern's own documentation got a
613
+ `NoMethodError` (as an alert flash) on every flag. It also passes `resolution_basis` when
614
+ the form sends one, and its flash says "Flag actioned." / "Flag dismissed." for flags.
615
+ - **Content spoken by something that isn't a person no longer breaks reports and flags.**
616
+ moderate stored `reportable.reported_owner` straight into `Report#reported_user` and
617
+ `Flag#owner`, both associations to `config.user_class`. A support desk or a bot behind the
618
+ content (chats answers `author || sender`, which is a desk for an unsigned support reply)
619
+ raised `ActiveRecord::AssociationTypeMismatch` and the report never filed. The new
620
+ `Moderate.responsible_user(record)` keeps the owner only if it is a person; anything else
621
+ means "nobody to reach": the report files, the evidence is kept, content can still be
622
+ removed, and only a decision notice or a ban has no addressee. Report intake, `Flag.flag!`,
623
+ the `:flag` after_commit and `ClassifyJob` all go through it. A reportable that never
624
+ defined `reported_owner` still raises at the first report. Hosts that overrode
625
+ `Chats::Message#reported_owner` to return nil for non-users can delete the override.
626
+ - **The gem's locale files can no longer override the host's.** The engine appended its
627
+ `config/locales` to `config.i18n.load_path`, which Rails loads AFTER the app's own locale
628
+ files — harmless while the directory was empty, but any shipped default would have silently
629
+ beaten the host's translation of the same key. Rails already loads an engine's
630
+ `config/locales` before the app's, so the initializer is gone.
631
+
632
+ ### Documentation
633
+
634
+ - **`docs/chats.md`**: the whole chats integration from moderate's side (reportable and
635
+ filtered messages, blocks, Report on a broadcast bubble, Block in the thread menu, the
636
+ admin side, a launch checklist), the shape two production hosts converged on.
637
+ - **`docs/madmin.md` runs as written.** It called `scope :resolved` (the scopes are
638
+ `actioned`), `attribute :kind` / `:details` (the columns are `intake_kind` / `message`),
639
+ `report.pending?` (now real), `Moderate.system_actor` (never existed; automated decisions
640
+ pass `by: nil`) and `flag.scores[:csam]` (scores are JSON with string keys, and the
641
+ canonical category is `sexual/minors`). The flag controller now shows enforcement, and a
642
+ new section covers "Flag for review" from a browse screen.
643
+
644
+ ### Upgrading from 1.0.0.beta2
645
+
646
+ No migrations. Check these against your app:
647
+
648
+ 1. **Wire `config.ban_handler`** if any moderator can press "ban": without one, a ban now
649
+ raises instead of being a silent no-op.
650
+ 2. **Reports are visibility-checked.** If you file community reports with a `reporter:` who
651
+ couldn't see the content, they now fail validation. Use `Flag.flag_for_review!` for an
652
+ administrator's own concern.
653
+ 3. **Delete the workarounds this release makes unnecessary**, if you wrote them:
654
+ - a `reported_owner` override that returns nil for non-users;
655
+ - a find-or-create-report bridge for removing or banning from a flag (use
656
+ `flag.action!(remove_content:, ban_user:)`);
657
+ - a hand-assembled manual `Flag.flag!` (use `Flag.flag_for_review!`);
658
+ - a model-to-`content_type` map in your report controller;
659
+ - your own "report and block" (use `report!(…, block: true)`);
660
+ - `to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)` (use
661
+ `to_moderation_sgid` or `moderate_report_target_path`);
662
+ - en/es translations of the gem's taxonomies and field names (keep only the ones you
663
+ changed);
664
+ - a duplicated `config.filter` next to a `moderates` declaration for the same field.
665
+ 4. **"Remove content" must remove something.** If your admin offers it on content whose
666
+ `remove_reported_field!` returns false, those decisions now fail instead of claiming a
667
+ removal. Gate the checkbox on `removable_reported_field?`.
668
+ 5. **`Flag#action!` re-closing** an already decided flag no longer overwrites the first
669
+ decision: it returns the flag as it was.
670
+ 6. **`Moderate.blocked_ids_for(nil)`** is an empty relation, not `[]`. Call `.ids` if you
671
+ need an Array.
672
+
673
+ ## [1.0.0.beta2] - 2026-07-10
674
+
675
+ Second beta on the road to 1.0. Fixes beta1's async-adapter routing bug (the one
676
+ that forced hosts to hand-roll their own enqueue), makes Active Storage
677
+ attachments filterable with zero wiring, and gives `Flag` first-class close
678
+ methods. Drop-in upgrade from beta1: no migrations, no breaking API changes —
679
+ and hosts that worked around the ClassifyJob bug can now delete the workaround.
680
+
681
+ ### Fixed
682
+
683
+ - **Async adapters now actually run in `Moderate::ClassifyJob`.** beta1's `:flag`
684
+ after_commit called `Moderate.classify` inline for *every* adapter — including
685
+ ones declaring `synchronous? == false` — so a remote moderation API ran its
686
+ network call inside the request that saved the content, and `ClassifyJob`
687
+ (whose docs promised this routing) was never enqueued by anything. The concern
688
+ now checks `config.adapter_async?(policy.adapter)` (new public helper, same
689
+ probe as the `:block` validator) and enqueues the job instead; the job re-reads
690
+ the persisted value and files the Flag itself. Hosts that worked around this by
691
+ enqueuing `ClassifyJob` themselves and short-circuiting
692
+ `moderation_field_changed_for_commit?` can delete both workarounds. (#3)
693
+
694
+ ### Added
695
+
696
+ - **Active Storage attachments filter out of the box.** `moderates :avatar, with:
697
+ :your_image_adapter, mode: :flag` on a `has_one_attached` model needs zero extra
698
+ wiring now: the concern snapshots `attachment_changes` in a `before_save` (AR
699
+ dirty tracking can't see attachment writes, and Active Storage clears the
700
+ changes before after_commit), consumes the snapshot at commit time, and both
701
+ the concern and `ClassifyJob` treat an unattached `ActiveStorage::Attached`
702
+ proxy as blank (nothing to classify — covers purge-between-enqueue-and-run).
703
+ The three `moderation_field_*` seam overrides remain for richer cases. (#3)
704
+ - **Flag close sugar.** `Flag#action!(note:, by: nil)` / `Flag#dismiss!(note:, by: nil)` —
705
+ model-level closes mirroring `Report#resolve!`/`#dismiss!`, so hosts stop hand-writing
706
+ status updates. `by:` stays nil for automated closes (don't fake a human in the audit
707
+ trail). Canonical automated use: dismiss a pending flag whose flagged content was
708
+ **superseded** (text edited, photo replaced/reverted) — left pending it keeps `flagged?`
709
+ true and mislabels the NEW content in any host UI keyed on it. (#4)
70
710
 
71
711
  ## [0.1.0] - 2024-11-03
72
712