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.
- checksums.yaml +4 -4
- data/.rubocop.yml +96 -1
- data/.simplecov +2 -10
- data/AGENTS.md +1 -1
- data/Appraisals +10 -8
- data/CHANGELOG.md +660 -20
- data/CLAUDE.md +1 -1
- data/README.md +149 -7
- data/Rakefile +2 -0
- data/app/controllers/concerns/moderate/moderation.rb +23 -11
- data/app/controllers/moderate/appeals_controller.rb +9 -5
- data/app/controllers/moderate/application_controller.rb +3 -3
- data/app/controllers/moderate/notices_controller.rb +3 -6
- data/app/helpers/moderate/engine_helper.rb +32 -11
- data/app/views/moderate/appeals/new.html.erb +6 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
- data/app/views/moderate/transparency_reports/show.html.erb +5 -5
- data/config/locales/en.yml +200 -0
- data/config/locales/es.yml +177 -0
- data/context7.json +4 -0
- data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
- data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
- data/docs/chats.md +140 -0
- data/docs/compliance.md +92 -3
- data/docs/configuration.md +30 -2
- data/docs/dsa-notice-form.md +1 -1
- data/docs/images/moderate-user-report-block-actions.webp +0 -0
- data/docs/madmin.md +44 -14
- data/docs/notifications.md +17 -4
- data/examples/aws_rekognition_adapter.rb +6 -6
- data/examples/openai_moderation_adapter.rb +1 -1
- data/examples/regexp_pattern_adapter.rb +68 -0
- data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
- data/gemfiles/rails_8.1.gemfile +6 -5
- data/lib/generators/moderate/install_generator.rb +25 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
- data/lib/generators/moderate/templates/initializer.rb +9 -15
- data/lib/generators/moderate/views_generator.rb +3 -3
- data/lib/moderate/attachments.rb +137 -0
- data/lib/moderate/configuration.rb +101 -12
- data/lib/moderate/engine.rb +86 -25
- data/lib/moderate/event.rb +21 -0
- data/lib/moderate/filters/base.rb +1 -0
- data/lib/moderate/filters/wordlist.rb +5 -2
- data/lib/moderate/jobs/classify_job.rb +100 -16
- data/lib/moderate/label.rb +2 -2
- data/lib/moderate/macros.rb +8 -3
- data/lib/moderate/models/appeal.rb +10 -1
- data/lib/moderate/models/block.rb +23 -19
- data/lib/moderate/models/classification.rb +24 -0
- data/lib/moderate/models/concerns/actor.rb +17 -2
- data/lib/moderate/models/concerns/content_filterable.rb +241 -56
- data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
- data/lib/moderate/models/concerns/reportable.rb +84 -4
- data/lib/moderate/models/flag.rb +199 -3
- data/lib/moderate/models/report.rb +180 -16
- data/lib/moderate/result.rb +3 -10
- data/lib/moderate/retention.rb +241 -0
- data/lib/moderate/services/intake_notice.rb +10 -10
- data/lib/moderate/services/intake_report.rb +42 -13
- data/lib/moderate/services/resolve_appeal.rb +8 -2
- data/lib/moderate/services/resolve_flag.rb +183 -35
- data/lib/moderate/services/resolve_report.rb +288 -35
- data/lib/moderate/text_validator.rb +15 -14
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +337 -17
- data/lib/tasks/moderate.rake +23 -0
- metadata +24 -14
- data/gemfiles/rails_7.2.gemfile +0 -36
- data/lib/moderate/text.rb +0 -38
- 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] -
|
|
7
|
+
## [1.0.0] - 2026-10-19
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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` (
|
|
44
|
-
|
|
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**,
|
|
48
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|