Skip to content
rameerezPublic

Latest commit

ย 

History

124 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐Ÿ›ก๏ธ moderate - Let your Rails users report content and block each other (Trust & Safety)

Gem Version Build Status

Tip

๐Ÿš€ Ship your next Rails app 10x faster! I've built RailsFast, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go check it out!

moderate gives your Rails app a complete Trust & Safety system.

Trust & Safety (T&S) is the system within an app that lets users report abusive content, block each other, filter objectionable text and images before they're posted (profanity, bad words, NSFW / nudity, etc.), and run a moderation queue your admins actually use. It also allows you to easily plug in automated AI moderation systems like OpenAI Moderation or AWS Rekognition to quickly filter, flag and/or automatically block harmful content (text or image).

If you have an app where users can upload / generate content or send messages to each other, you probably need a Trust & Safety system.

Moderate gem sample use case for reporting profiles and blocking users

moderate ships with mechanisms aligned with the DSA (EU Digital Services Act), and also aligned with the Apple App Store and Android's Google Play directives for User-Generated Content (UGC) in their app stores.

๐Ÿ‘จโ€๐Ÿ’ป Example

moderate reads like plain English. Make any model reportable:

class Comment < ApplicationRecord
  has_reportable_content
end

Let any user block another:

current_user.block!(@other_user)
current_user.blocks?(@other_user)   # => true

Filter content before it's posted โ€” the zero-setup wordlist, or a real classifier like OpenAI moderation:

# config/initializers/moderate.rb โ€” wire up OpenAI moderation once (text AND images)
config.register_adapter :openai, OpenAIModerationAdapter.new
class Message < ApplicationRecord
  # Run every DM through OpenAI, but never block mid-conversation: `:flag` lets the
  # message send, then classifies it in a background job and drops anything harmful
  # into the moderation queue for review.
  moderates :body, mode: :flag, with: :openai
end

No API keys to start? Drop the with: and you get the built-in, zero-dependency :wordlist (a fast, multilingual profanity block) โ€” same one-line API.

And give admins a real queue to act on:

Moderate::Report.pending           # everything awaiting a decision
report.resolve!(by: current_user, remove_content: true, ban_user: true, note: "Hate speech")

That's the whole idea: the messy, legally-loaded plumbing every social/UGC app needs (report, block, filter, moderate, appeal, comply) as one coherent Ruby gem instead of scattered, half-finished, store-rejecting DIY code.

Note

moderate is UI-agnostic by design: most of a Trust & Safety system lives in admin surfaces, so the gem ships the primitives (models, services, helpers, controller concerns) and lets you build your own UI. It plugs into madmin (or any admin system) in minutes; see Admin & moderation queue.


Quickstart

moderate needs Ruby 3.3+ and Rails 8.0.5.1+ (8.0 and 8.1 are tested), on SQLite, PostgreSQL or MySQL.

Add the gem:

gem "moderate"

Install it (creates the migration + an initializer):

bundle install
rails generate moderate:install
rails db:migrate

Tell moderate who your users are, and make a model reportable:

# config/initializers/moderate.rb
Moderate.configure do |config|
  config.user_class = "User"
end
class User < ApplicationRecord
  has_reporting_and_blocking      # can report, block, be blocked, be banned
end

class Post < ApplicationRecord
  has_reportable_content          # users can report it
  moderates :body, mode: :block   # โ€ฆand profanity is rejected on save โ€” zero-setup built-in wordlist
end

That's it. You now have reporting, blocking, filtering, and a moderation queue. Everything below is detail.


Why this gem exists

Every app with user-generated content eventually faces the same wall. A user posts something vile, another user wants them gone, Apple rejects your build for "no way to report objectionable content," and a Spanish lawyer emails you about the Digital Services Act. So you start bolting on a reports table, a blocks table, a profanity regex, an admin page, a "notify the reporter" emailโ€ฆ and it's suddenly a sprawling, half-correct subsystem entangled with your core app.

It's the kind of plumbing nobody wants to build, everybody rebuilds, and almost everybody ships incomplete โ€” which is exactly what gets apps rejected from the stores and exposed under the DSA. moderate is the single, opinionated, batteries-included source of truth for it:

  • Report users and content (in-app), with evidence snapshots and a real decision workflow.
  • Block users (bidirectional), enforced everywhere a blocked pair could reconnect.
  • Filter text and images before they're posted (:off / :block / :flag), with pluggable backends โ€” a built-in offline wordlist, plus ready-to-copy reference adapters in examples/ (OpenAI, AWS Rekognition) or your own.
  • Moderate from a queue: remove content, ban users, dismiss, all audited.
  • Align with the core DSA / store-review mechanisms: notice-and-action (Art. 16), statement of reasons (Art. 17), internal appeals (Art. 20), transparency counters (Art. 24); Apple Guideline 1.2 and Google Play UGC requirements.

Typical offending content include categories like these, all covered by the moderate gem: harassment, hate, threats, sexual_content, spam, fraud, unsafe_behavior, illegal_content, privacy, child_safety, other, hate_abuse_harassment, violent_speech, graphic_violent_media, illegal_regulated_behaviors, impersonation, adult_sexual_content, private_non_consensual_content, suicide_self_harm, terrorism_violent_extremism, scam_fraud

Important

The moderate gem is not a compliance certificate. You still own your policies, legal review, published contact information, jurisdiction-specific obligations, and day-to-day moderation operations. For example, EU DSA Article 19/24 complaint-handling and transparency duties have size/tier carve-outs (including micro/small enterprise exemptions); moderate just gives you the mechanisms when you need them, not a legal conclusion that every app must use every surface.

What moderate does and doesn't do

Does:

  • User & content reporting (in-app) + a public DSA legal-notice intake form.
  • Blocking with a single source-of-truth query you enforce in search, messaging, profiles, anywhere.
  • Pre-publication content filtering with three modes and pluggable adapters โ€” the built-in offline wordlist (text), plus image/LLM moderation via reference adapters you register (see examples/).
  • A moderation queue with audited resolve / dismiss / remove-content / ban actions.
  • Appeals, statement-of-reasons notifications, and transparency aggregation for the DSA.
  • Optional audit and notification hooks that fan out to your mailer / admin alerts / push.

Doesn't (on purpose โ€” these are other tools' jobs):

  • Authentication / current-user (that's Devise โ€” you tell moderate your user class).
  • Sending the actual emails/push (that's goodmail / noticed โ€” moderate just emits events).
  • The admin UI chrome (that's madmin / your app โ€” moderate gives you the data + primitives).
  • A bulletproof ML classifier out of the box (the default text filter is a fast, multilingual wordlist; bring an LLM/image adapter when you want one).

๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Actors: report & block

Add has_reporting_and_blocking to your user model (or any model that acts on behalf of a person):

class User < ApplicationRecord
  has_reporting_and_blocking
end

(Prefer an explicit include? include Moderate::Actor is the exact equivalent โ€” the macro just lazily includes it.)

Blocking is a bidirectional safety edge โ€” once either side blocks, neither should see or reach the other:

current_user.block!(@other)     # idempotent; audited; fires your on_block hook
current_user.unblock!(@other)
current_user.blocks?(@other)    # I blocked them
current_user.blocked_by?(@other)
current_user.blocked_with?(@other)   # either direction โ€” the one you check in features

Enforce it anywhere with the single source-of-truth query (no hand-rolled block SQL ever again):

# Hide blocked people from a marketplace / search / inbox:
Post.where.not(user_id: Moderate.blocked_ids_for(current_user))

Reporting content or a person:

current_user.report!(@message, category: :harassment, details: "Won't stop messaging me")
current_user.report!(@user,    category: :impersonation)
current_user.report!(@message, category: :spam, block: true)   # "Report and block"

moderate snapshots the offending content at report time (so evidence survives edits/deletes), infers who's responsible, records what kind of content it is (moderation_content_type), sends the reporter a receipt, and drops it in the queue. A report is refused when the reporter may not report the content (the reportable's own report_visible_to?: never your own content, never a conversation you're not in), whether or not the link was hidden.

Your report form's category select is one line, labeled in the reader's language (the gem ships English and Spanish for every value it defines, and your locale files win):

<%= f.select :category, Moderate::Report.category_options %>   <%# honors config.report_categories %>

Moderate.taxonomy_label(kind, value) labels the rest of the gem's vocabulary the same way in your queue: Moderate.taxonomy_label(:flag_status, flag.status), (:legal_reason, report.legal_reason), (:decision, report.decision_visibility), and so on.

๐Ÿšฉ Reportable content

Declare what can be reported with one has_reportable_content line โ€” the fields are optional (omit them to report the whole record):

class Listing < ApplicationRecord
  has_reportable_content :title, :description

  # Tell moderate how to present & clean this content when a moderator acts:
  def moderation_label = "Listing #{id}"
  def reported_owner   = user                # who's responsible (defaults sensibly)
end

(Explicit-include equivalent: include Moderate::Reportable + reportable_fields :title, :description.)

Give each field the name a human should see. Without a label, a report of a chat message reaches your moderators as ยซBodyยป and they have to guess what was reported โ€” declare it once, next to the field:

class Message < ApplicationRecord
  has_reportable_content :body, :files, labels: { body: "Chat message", files: "Chat photo" }
end

report.reported_field_label                    # => "Chat message"
flag.field_label                               # => "Chat message"
Moderate.reportable_field_label(Message, :body) # => "Chat message" (also takes a record, or a "Message" type string)

moderate prints the label wherever it names a field โ€” the public appeal page, the statement-of-reasons payload (reported_field_label), the report_received / content_flagged summaries โ€” and keeps the raw name next to it (reported_field, field) for your code. Labels are optional and per field; each field resolves to the first of:

  1. its labels: entry โ€” a String, or a lambda evaluated at read time (-> { I18n.t("chat.message") });
  2. moderate.reportable_fields.<model>.<field> in your locale files, where <model> is the model's model_name.i18n_key (chats/message for Chats::Message; STI children also try their reportable parent);
  3. moderate.reportable_fields.<field> โ€” one label for that field name on every model. moderate ships en and es defaults for the common ones (body โ†’ "Text" / "Texto", avatar โ†’ "Profile photo" / "Foto de perfil", โ€ฆ), and your locale files override them;
  4. field.humanize.
# config/locales/es.yml โ€” the multi-locale way to say the same thing
es:
  moderate:
    reportable_fields:
      chats/message:
        body: "Mensaje de chat"
        files: "Foto de chat"

Model-scoped and field-wide keys share one namespace, so a model whose key matches a field name (Listing and a listing field) can't also have a field-wide label for that name โ€” scope it to the model instead. A whole-record report has no field, so its label is nil.

You get:

listing.reports          # reports filed against this record
listing.reported?        # any open report?
listing.flagged?         # any pending flag (auto-filter OR manual)?
listing.flagged?(:description) # field-level pending flag?

Drop a report link into any view with the helper (it renders nothing if the viewer can't report the content):

<%= moderate_report_link(@listing, field: :description) %>

For markup rendered once and shown to many viewers (a chat bubble broadcast to everyone in the thread, a cached fragment), where the per-viewer check can't run, use the viewer-agnostic path and hide it on the author's own content client-side; the server still refuses a report the viewer may not make:

<%= link_to "Report", moderate_report_target_path(message, field: :body) %>   <%# target: message.to_moderation_sgid, never expires %>

Using chats for messages? Its messages already speak this contract; the whole integration is in docs/chats.md.

Because moderate is UI-agnostic, it does not render a built-in "under review" badge. Use flagged? / flagged?(:field) to render copy that fits your product when :flag mode lets content through but queues it for review.

If your app runs inside Hotwire Native / Turbo Native, remember that native path configuration is host-owned. Add rules for the in-app report routes you mount (for example /reports/new and the form action /reports, so validation errors stay in the same modal stack) and for the engine's public legal routes and their form actions such as <mount>/notices/new, <mount>/notices, <mount>/appeals/new, <mount>/appeals, and <mount>/transparency โ€” where <mount> is wherever you mounted Moderate::Engine in your routes (it is host-chosen, not fixed). moderate can provide the Rails routes; your native shell still decides whether they push, present modally, use a sheet, and which Android uri maps to the destination.

Adding a new reportable type is one has_reportable_content line โ€” the intake, queue, snapshot, and admin code never change.

๐Ÿงช Content filtering: :off / :block / :flag

Filtering is one declaration per field, with three modes:

class Message < ApplicationRecord
  moderates :body                       # uses the default mode (see config)
end

class Profile < ApplicationRecord
  moderates :bio,    mode: :block       # reject the save if it trips the filter
  moderates :avatar, mode: :flag, with: :image   # `:image` = a registered adapter (see examples/); only :wordlist ships built in
end
  • :off โ€” no check.
  • :block โ€” the write is rejected with a validation error (great for public, high-trust fields).
  • :flag โ€” the write succeeds, and a Moderate::Flag is created after commit for human or automated review (great for DMs, where you don't want to block mid-conversation).

Why this matters: :flag never lives in a validator (validators must be side-effect-free, and a flag created inside a rolled-back transaction would silently vanish) โ€” moderate handles that correctly for you.

Active Storage attachments (has_one_attached / has_many_attached) take the same one line, moderates :avatar, with: :image, mode: :flag, and no other wiring. The file is classified once it is on the storage service, never before, whichever of moderates and has_one_attached you declare first, and a failed upload is never silent. Just before Active Storage uploads, moderate queues a Moderate::ClassifyJob for 5 seconds later; once the upload returns, it classifies right away (inline for a synchronous adapter, by an immediate job for an async one) if the file is there. Whichever comes first claims the files (a row in the gem's moderate_classifications table, written under the content record's row lock, held only for the claim), calls your adapter outside any lock, and marks the row classified; the other sees the claim or the mark and stops. So each set of files is classified once per record (a changed set, or the same file on another record, is classified again), and a slow provider never blocks writes to the content's row. A claim expires after config.classification_claim_ttl (10 minutes), so a worker that died mid-call doesn't leave the files unclassified. A file that isn't there (a direct upload still in flight, an upload that raised) is left to the job, which looks again 5, 10, 15 and 20 seconds later, then gives up with a classification_skipped audit event instead of handing your adapter a missing file. Under mode: :block, an attachment whose direct upload hasn't finished fails validation ("Photo hasn't finished uploading yet", en/es, key errors.messages.upload_not_finished) rather than being checked without its file: nothing enters unverified.

Check content directly anywhere:

result = Moderate.classify("some sketchy text")
result.allowed?    # => false
result.categories  # => [:hate, :"hate/threatening"]
result.scores      # => { "hate" => 0.97, "hate/threatening" => 0.81 }   (0..1 for service adapters)
result.labels      # => [#<Label category: :hate, subcategory: :threatening, score: 0.81, input: :text>, โ€ฆ]

Filter adapters (the built-in wordlist, reference adapters, your own โ€” one interface)

Every backend implements the same tiny contract โ€” classify(value) โ†’ Moderate::Result โ€” so they're interchangeable per field. moderate ships exactly one built-in adapter, the offline :wordlist; OpenAI, AWS Rekognition, and anything else are bring-your-own โ€” copy a ready-made reference adapter from examples/, add its gem to your Gemfile, and register_adapter it:

Adapter Use it for Notes
:wordlist (built-in, default) text Fast offline baseline, multilingual, zero-dependency. Includes Unicode normalization and common substitution handling, but it is not a contextual classifier. Ships en/es lists; add your own. The only adapter the gem ships.
OpenAI (reference adapter โ€” examples/openai_moderation_adapter.rb) text and image OpenAI omni-moderation-latest via the ruby_llm gem โ€” free, multimodal, its category set IS the canonical taxonomy + 0..1 scores. Copy it in, gem "ruby_llm", register_adapter(:openai, โ€ฆ). Runs async (Moderate::ClassifyJob) in :flag mode.
AWS Rekognition (reference adapter โ€” examples/aws_rekognition_adapter.rb) images / avatars detect_moderation_labels via aws-sdk-rekognition, with its taxonomy mapped onto the canonical labels. Copy it in, gem "aws-sdk-rekognition", register_adapter(:rekognition, โ€ฆ). Async, :flag mode.
your own anything register_adapter(:replicate, โ€ฆ) / Perspective / a self-hosted model โ€” any object responding to classify. No built-in pretends the backend must be an "LLM".

All adapters map their provider labels onto one canonical taxonomy (OpenAI's: harassment[/threatening], hate[/threatening], sexual[/minors], self-harm[/intent|/instructions], violence[/graphic], illicit[/violent]), so Moderate::Flag, the DSA statement of reasons, and the transparency counters all speak one vocabulary.

Moderate.configure do |config|
  config.default_filter_mode = :block
  config.filter_adapter      = :wordlist

  # Bring an external classifier: copy examples/openai_moderation_adapter.rb into
  # your app, add `gem "ruby_llm"`, then register and use it by name.
  config.register_adapter :openai, OpenAIModerationAdapter.new

  config.filter "Message", :body,   with: :wordlist, mode: :flag
  config.filter "Profile", :avatar, with: :openai,   mode: :flag   # one adapter moderates text AND images
end

:block requires a synchronous adapter (:wordlist) โ€” you can't reject a save on a background result. The async reference adapters (the OpenAI/Rekognition examples) declare synchronous? == false, so they run in :flag mode (allow the write, classify in a job, file a Moderate::Flag). moderate validates this for you and says so.

Bring your own adapter โ€” it's just an object that responds to classify:

class MyAdapter
  def classify(value) = Moderate::Result.new(allowed: ..., categories: [...], scores: {...})
end
Moderate.register_adapter(:my_adapter, MyAdapter.new)

The original moderate (โ‰ค 0.1) was only a profanity validator. That validates :field, moderate: true one-liner still works โ€” it's now the :wordlist adapter in :block mode. See Upgrading from 0.x.

๐Ÿ› ๏ธ Admin & the moderation queue

Most of Trust & Safety happens in admin. moderate gives you the primitives; you bring the UI.

Moderate::Report.pending             # the report queue
Moderate::Flag.pending               # the auto-filter queue (human OR ML consumer reads the same scope)
Moderate::Appeal.pending             # appeals awaiting a human

report.resolve!(by: admin, remove_content: true, ban_user: false, note: "Removed: hate speech")
report.dismiss!(by: admin, note: "No violation")
flag.resolve!(by: admin, note: "Slur", remove_content: true)   # enforces through a report (below)
flag.dismiss!(by: admin, note: "False positive")
appeal.uphold!(by: admin, note: "...")   # overturns the decision
appeal.reject!(by: admin, note: "...")

Moderate::Flag.flag_for_review!(message, field: :body, by: admin)   # "Flag for review" from any browse screen

Every action is atomic, requires a moderator + a note, runs your enforcement (content removal via the reportable's own remove_reported_field!, bans via your ban_handler), and writes to your audit log. A ban needs a ban_handler: without one, ban_user: true raises instead of telling someone their account was suspended when nothing suspended it. And a report's resolve! restricts something (remove_content:, ban_user:, or both): one with neither is refused, because its statement of reasons would claim a restriction nobody imposed; a decision that restricts nothing is a dismiss!.

A flag has no reporter, statement of reasons or appeal window, but removing content or suspending an account is a restriction that needs all three. So remove_content: / ban_user: on a flag escalate it: the open report on the same content and field (if a person reported it too) or a system one is resolved under the flag's lock, and flag.report is that report afterwards. Without them, resolving a flag only triages it.

Use it from a controller (BYOUI)

class Admin::ReportsController < ApplicationController   # the same concern drives Admin::FlagsController
  include Moderate::Moderation   # resolve!/dismiss! actions, strong params, redirects

  before_action :require_admin
end

Integrate with madmin

moderate's models are plain ActiveRecord, so they show up in madmin like anything else. Generate a resource and point it at the model:

rails generate madmin:resource Moderate::Report

Then wire the resolve/dismiss actions to Moderate::Report#resolve!/#dismiss! from a custom member action (full walkthrough in docs/madmin.md). The same pattern works for Moderate::Flag and Moderate::Appeal.

๐Ÿ”” Notifications & ๐Ÿงพ audit โ€” one hook each

moderate never sends an email or writes to your audit log directly. It emits events through two hooks you wire once โ€” so notifications fan out wherever you want, and important actions are recorded however you want.

Moderate.configure do |config|
  # Called for every important action โ€” wire it to your audit system (or leave it; it no-ops):
  config.audit = ->(event) { AuditLog.record!(event_type: event.name, data: event.payload) }

  # Called for every notifiable event โ€” fan out to email / admin Telegram / push / in-app:
  config.notify = ->(event) do
    case event.name
    when :report_received, :report_decision, :affected_user_decision
      ModerationMailer.with(event:).public_send(event.name).deliver_later   # goodmail
    when :content_flagged, :report_received
      Telegrama.send_message("๐Ÿšฉ #{event.payload[:summary]}")               # admin alert
    end
  end

  # Optional side effects when a block happens (e.g. tear down a pending invite):
  config.on_block = ->(blocker:, blocked:, at:) { CancelPendingInvites.call(blocker, blocked, at: at) }
end

Events carry a stable envelope (event.name, event.subject, event.recipients, event.actor, event.payload), so a single notify hook can drive goodmail (user emails), telegrama (admin alerts), and noticed (in-app feed + push) at once. Notify users via email/in-app and ping admins on Telegram from the same place. (Recipes in docs/notifications.md.)

Notices follow Active Record's after_commit rules. A decision, a report's receipt, a block: each notice belongs to the transaction it happened in and is sent when that transaction commits, never if it rolls back. If you decide inside a transaction of your own, nothing goes out until yours commits; inside nested transactions and savepoints, at the outermost commit; under a parent that isn't joinable (Rails' transactional tests run every test in one), when the savepoint is released, the same moment your models' after_commit runs; with no transaction open, at once. Under multiple databases or shards, queue a notice inside the transaction that saved the record, on that transaction's connection: the gem's services capture their connection before they save and pass it along, so a host callback that switches databases mid-save can't move them; if you call record.after_commit_notice { โ€ฆ } yourself under connected_to, pass on: connection (it defaults to the record class's connection at call time).

The full event vocabulary: report_received, notice_received, report_decision, affected_user_decision, appeal_received, appeal_decision, user_blocked, user_unblocked, user_banned, content_flagged, content_removed.

โš–๏ธ DSA & app-store compliance, out of the box

moderate is built around the rules so you don't have to read the regulation:

  • DSA Art. 16 (notice & action): a public, electronic notice form โ€” a mountable engine you place at the path of your choosing (mount Moderate::Engine => "/trust", no hardcoded /legal) โ€” capturing the substantiated reason, exact URL, notifier name+email, good-faith statement, the EU statement-of-reasons taxonomy, and the member-state selector, with an automatic confirmation of receipt. A notice is a Moderate::Report with intake_kind: "dsa" (no separate model), built via Moderate::Services::IntakeNotice. The form prefills the reported-content fields from query params (editable) and a signed-in notifier's identity (locked), and auto-integrates rails_cloudflare_turnstile when present (falling back to a config.notice_guard proc, with an optional per-request skip hook for clients that cannot render a browser challenge). See docs/dsa-notice-form.md.
  • DSA Art. 17 (statement of reasons): decision notices state the action, the legal/contractual ground, whether automated means were used, and the redress path.
  • DSA Art. 20 (appeals): a free, electronic internal complaint mechanism, open โ‰ฅ 6 months, decided by a human.
  • DSA Art. 24 (transparency): counters you can publish (notices received, actions taken, median handling time, appeal outcomes). The public transparency page is opt-in (config.transparency_report_enabled = true, off by default) โ€” a live portal isn't itself required (the duty is to publish a report, and micro/small enterprises are exempt), so you turn it on only when you want it.
  • Retention (GDPR storage limitation): nothing is purged unless you ask. Moderate.purge(older_than: 1.year) (or bin/rails moderate:purge, opt-in, safe to run nightly) redacts the content copies and contact details from reports, flags and appeals whose decision is final, and keeps every decision and every transparency count. Moderate.hold(report, until:) keeps a report under external dispute out of it. What is stored, for how long, and why: docs/compliance.md#5-retention-what-moderate-keeps-and-for-how-long.
  • Apple Guideline 1.2 & Google Play UGC: filter-before-post, in-app report and block, ongoing moderation, published contact โ€” moderate covers all four. See the mapped checklist in docs/compliance.md.

Two taxonomies, on purpose: an in-app community report category set (harassment, spam, โ€ฆ) and a separate, regulator-aligned DSA legal-reason taxonomy for public notices. moderate ships both. The community set is host-customizable via config.report_categories; the DSA legal-reason taxonomy is regulator-defined and fixed.

๐Ÿค“ Why the models?

rails generate moderate:install creates four tables:

  • moderate_reports โ€” a report/notice + an immutable evidence snapshot + decision metadata + the appeal window. Serves both in-app reports and public DSA notices (distinguished by intake_kind).
  • moderate_blocks โ€” the bidirectional blocker/blocked edge, with a self-block check and the SSOT relation behind Moderate.blocked_ids_for.
  • moderate_flags โ€” system/auto-filter flags (source: text_filter / image_filter / external_classifier / manual), with the classifier's labels + scores; the queue both human admins and ML consumers read via pending.
  • moderate_appeals โ€” DSA Art. 20 internal complaints against a decision.

The value-list taxonomies (community category, status, content_type, the DSA legal_reason/legal_country_code, resolution_basis, plus Flag source/mode/status and Appeal source/status) are validated in the models โ€” frozen constants + ActiveModel inclusion validations โ€” not by database CHECK constraints. That means adding or customizing a label never requires a migration: the community category list is host-overridable via config.report_categories (defaults to Moderate::Report::DEFAULT_CATEGORIES), and the gem can grow its own taxonomies in a point release without touching your schema. The only value guard kept at the DB level is a cheap message-length backstop; everything else the migration adds is structural (NOT NULLs, FKs, the unique block edge, and the self-block CHECK).

The migration is adaptive: it matches your app's primary-key type (UUID or bigint) and JSON column type (jsonb / json) automatically, so it drops cleanly into any Rails 8.0+ schema.

Configuration reference

Moderate.configure do |config|
  config.user_class        = "User"          # who reports/blocks/gets banned
  config.default_filter_mode = :block        # :off / :block / :flag
  config.filter_adapter    = :wordlist       # default text adapter

  config.audit       = ->(event) { ... }     # optional; no-op by default
  config.notify      = ->(event) { ... }     # optional; no-op by default
  config.on_block    = ->(blocker:, blocked:, at:) { ... }   # optional
  config.ban_handler = ->(user:, by:, reason:) { user.suspend! }   # how a "ban" is applied in your app (required before any ban_user: true)

  config.filter "Message", :body, with: :wordlist, mode: :flag
end

Reportable classes are auto-discovered from the has_reportable_content macro (or include Moderate::Reportable) โ€” no manual registry.

What is public

From 1.0 on, everything listed here keeps its name, its arguments and its meaning until 2.0. Anything not listed (and anything tagged @api private in the source) may change in any release. test/public_api_test.rb reads this section and fails if a name listed here doesn't exist, if a public Moderate.* method or configuration key isn't listed, or if the event lists drift from what the gem emits.

Declarations (Moderate::Macros, on every Active Record model)

#has_reporting_and_blocking ยท #has_reportable_content (*fields, labels:) ยท #moderates (*fields, mode:, with:). The include forms are equivalent: Moderate::Actor; Moderate::Reportable with Moderate::Reportable::ClassMethods#reportable_fields; Moderate::ContentFilterable with Moderate::ContentFilterable::ClassMethods#moderates_fields.

Moderate::Actor (your user model)

#report! (reportable, category:, details: nil, block: false, field:, โ€ฆ) ยท #block! ยท #unblock! ยท #blocks? ยท #blocked_by? ยท #blocked_with?

Moderate::Reportable (your content models)

What you define (each has a default): #reported_owner (required in practice) ยท #moderation_label ยท #moderation_snapshot ยท #moderation_content_type ยท #report_visible_to? ยท #remove_reported_field! ยท #removable_reported_field? ยท #moderation_subject_url ยท #moderation_return_path ยท #moderation_admin_path. The removal seam's contract: #remove_reported_field!(field) removes that field's content as it is when called (clear it, purge the attachment, soft-delete the record so deleted? is true), persists that, and returns whether anything was removed; it must not rewrite the field to other content first. The gem verifies it: after a true return the field is read again under the same lock, and unless it reads as gone (through #moderation_snapshot and the field itself, or deleted?) the decision is refused as "nothing was removed". What you get: #reports ยท #open_reports ยท #reported? ยท #flagged? ยท #moderation_flags ยท #pending_moderation_flags ยท #to_moderation_sgid ยท #reportable_field_label ยท #reportable_field_allowed? ยท Moderate::Reportable::ClassMethods#reportable_field_label.

Moderate::ContentFilterable (seams you may override)

#moderation_field_value ยท #moderation_field_changed_for_commit? ยท #moderation_field_committed

Moderate (the module)

.configure ยท .config ยท .configuration ยท .reset! ยท .user_class ยท .reportable_classes ยท .classify ยท .filter_policy_for ยท .register_adapter ยท .blocked_ids_for ยท .responsible_user ยท .reportable_field_label ยท .taxonomy_label ยท .transparency ยท .delivered! (report, :decision | :affected_user, at:) ยท .purge ยท .hold (report, until:, by:, reason:) ยท .release_hold ยท Moderate::TAXONOMY_LABEL_SCOPES ยท Moderate::VERSION

Moderate::Report

Queries: .pending ยท .open ยท .actioned ยท .dismissed ยท .recent_first ยท .report_categories ยท .category_options ยท .locate_signed_reportable. Decisions: #resolve! (by:, note:, remove_content: false, ban_user: false, resolution_basis: "terms") ยท #dismiss! (by:, note:). Readers: #pending? ยท #open? ยท #closed? ยท #actioned? ยท #dismissed? ยท #dsa? ยท #community? ยท #anonymous_notice? ยท #automated_processing_used? ยท #reported_field_label ยท #reportable_label ยท #notifier_label ยท #reported_content_text ยท #subject_url_list ยท #safe_subject_url ยท #safe_subject_urls ยท #safe_subject_url_for ยท #signed_appeal_gid ยท #held? ยท #covered_by ยท #skip_received_notice ยท #appeals ยท #flags. Value lists: ::DEFAULT_CATEGORIES ยท ::DSA_LEGAL_REASONS ยท ::EU_COUNTRY_CODES ยท ::CONTENT_TYPES ยท ::RESOLUTION_BASES ยท ::STATUSES ยท ::INTAKE_KINDS ยท ::APPEAL_WINDOW ยท ::MESSAGE_MAX_LENGTH.

Moderate::Flag

.pending ยท .actioned ยท .dismissed ยท .recent_first ยท .flag_for_review! (record, field:, by:, note: nil) ยท .flag! (flaggable:, field:, owner:, source:, mode:, excerpt:, categories:, scores:, context:, for a host's own classifier pipeline) ยท .sources ยท #action! (note:, by: nil, remove_content:, ban_user:, resolution_basis:) ยท #resolve! (its alias) ยท #dismiss! (note:, by: nil) ยท #pending? ยท #closed? ยท #report ยท #field_label ยท #flaggable_label ยท ::STATUSES ยท ::SOURCES ยท ::MODES

Moderate::Appeal

.pending ยท .open ยท .upheld ยท .rejected ยท .recent_first ยท #uphold! (by:, note:) ยท #reject! (by:, note:) ยท #pending? ยท #open? ยท #closed? ยท #upheld? ยท #rejected? ยท ::STATUSES ยท ::SOURCES

Moderate::Block

.block! (blocker:, blocked:) ยท .unblock! (blocker:, blocked:) ยท .between ยท .recent_first. Read the edge set with Moderate.blocked_ids_for(user): always a relation of user ids, .ids for an Array.

Services (the long form of the decision methods, same keywords)

Moderate::Services::ResolveReport (.new(report, by:), #resolve!, #dismiss!) ยท Moderate::Services::ResolveFlag (.new(flag, by:), #action!, #dismiss!) ยท Moderate::Services::ResolveAppeal (.new(appeal, by:), #uphold!, #reject!) ยท Moderate::Services::IntakeReport ยท Moderate::Services::IntakeNotice ยท Moderate::Services::IntakeAppeal (each #save).

Jobs, adapters and value objects

Moderate::ClassifyJob (perform_later(record, field); ::UPLOAD_WAIT, ::UPLOAD_ATTEMPTS) ยท the adapter contract: any object with classify(value) returning a Moderate::Result (or a Hash of its fields), optionally synchronous?; Moderate::Filters::Base to inherit from ยท Moderate::Filters::Wordlist (:wordlist) ยท Moderate::Result (.allowed, #allowed?, #flagged?, #categories, #scores, #labels, #source, #raw) ยท Moderate::Label (#slug, #canonical?, ::CATEGORIES) ยท Moderate::Error ยท Moderate::ConfigurationError ยท the 0.x validator validates :field, moderate: true.

Views and controllers

Helpers (Moderate::EngineHelper): #moderate_report_link ยท #report_link ยท #moderate_report_target_path, and the seam #moderate_current_viewer. Admin decisions (Moderate::Moderation, a controller concern): #resolve ยท #dismiss ยท #uphold ยท #reject, and the seams #moderation_record ยท #moderation_actor ยท #moderation_note ยท #moderation_decision_params ยท #moderation_redirect_path. The engine (mount Moderate::Engine => "/trust"): new_notice_path / notices_path, new_appeal_path / appeals_path (token:, source:), transparency_path; record.after_commit_notice (Moderate::DeferredNotices#after_commit_notice) on the gem's records. Generators: moderate:install, moderate:views. Rake: moderate:purge.

Configuration (Moderate.configure do |config| โ€ฆ end)

config.user_class ยท config.default_filter_mode ยท config.filter_adapter ยท config.additional_words ยท config.excluded_words ยท config.report_categories ยท config.register_adapter ยท config.filter ยท config.audit ยท config.notify ยท config.on_block ยท config.ban_handler ยท config.banned ยท config.locale ยท config.classification_claim_ttl ยท config.migration_copies_check ยท config.parent_controller ยท config.notice_form_enabled ยท config.notice_rate_limit ยท config.notice_turnstile_site_key ยท config.notice_turnstile_secret_key ยท config.notice_captcha_verifier ยท config.notice_guard ยท config.notice_human_verification_skip_if ยท config.appeal_form_enabled ยท config.appeal_rate_limit ยท config.appeal_guard ยท config.appeal_human_verification_skip_if ยท config.appeal_return_path ยท config.transparency_report_enabled. Read-only: config.adapters ยท config.filters ยท config.adapter_for ยท config.adapter_registered? ยท config.adapter_async? ยท config.ban_handler_configured? ยท config.validate!.

Events and the notifier contract

Notify events (Moderate::Event::NOTIFY_NAMES): :report_received ยท :notice_received ยท :report_decision ยท :affected_user_decision ยท :appeal_received ยท :appeal_decision ยท :user_blocked ยท :user_unblocked ยท :user_banned ยท :content_flagged ยท :content_removed

Audit events (Moderate::Event::AUDIT_NAMES): :report_received ยท :appeal_received ยท :report_decision ยท :flag_decision ยท :appeal_decision ยท :flag_filed ยท :user_blocked ยท :user_unblocked ยท :user_banned ยท :content_removed ยท :notify_failed ยท :classification_skipped ยท :retention_purged ยท :legal_hold_placed ยท :legal_hold_released

Every hook receives one Moderate::Event: #name ยท #subject ยท #actor ยท #recipients ยท #payload (always with :summary) ยท #occurred_at ยท #id ยท #summary ยท #to_h. The contract for config.notify: it is called once per event, after the transaction that caused it commits (never for a rolled-back change); a truthy return means "handed off" (for a DSA notice the gem then treats the Art. 16(4) receipt as sent); a decision notice counts as delivered only when your notifier confirms it with Moderate.delivered!(report, :decision | :affected_user) from the mail's actual delivery, which stamps decision_notified_at / affected_user_notified_at once and starts the appeal window from then (DSA Art. 20(2)); an exception is swallowed, audited as :notify_failed, and never rolls back the decision. config.audit follows the same rules and its return value is ignored. Payload keys may be added in a minor release, never removed or renamed.

Upgrading within 1.0

1.0.1 adds a migration (moderate_classifications.record_id becomes a string, so apps that mix uuid and bigint primary keys classify attachments on both): run bin/rails moderate:install:migrations if your app holds copies of the gem's migrations, then bin/rails db:migrate. The copies follow the RuboCop style of a new Rails app (rubocop-rails-omakase), so they need no exclude. In bin/rails db:migrate:status a copied migration's name ends in .moderate (Add pending key to moderate flags.moderate): that is Rails' own naming for migrations copied from an engine, not a broken copy.

Upgrading from a 1.0 beta

1.0.0 adds a migration (retention columns and moderate_classifications): run bin/rails moderate:install:migrations if your app holds copies of the gem's migrations (*.moderate.rb, as every app installed from 1.0.0.beta4 on does), then bin/rails db:migrate. An app without copies gets it through the engine, like beta4's. A missed copy is no longer silent: at boot, an app that holds copies but not one of this version's migrations raises Moderate::ConfigurationError naming it and the command that copies it (under a rake task, such as that command itself, it logs instead); config.migration_copies_check = :log or :off changes that.

1.0.0.beta4 adds a migration. After upgrading, run bin/rails db:migrate: the engine adds the gem's own db/migrate to your app's migration paths, so it runs with yours. (If your app holds copies of the gem's migrations, *.moderate.rb files from bin/rails moderate:install:migrations, the engine adds nothing and the copies run instead: run bin/rails moderate:install:migrations to copy the new one, then migrate.) A fresh install from 1.0.0.beta4 on gets the same migration from rails generate moderate:install, copied right after the tables, so it holds copies: after each later upgrade, run bin/rails moderate:install:migrations, then migrate. It gives moderate_flags a pending_key with a unique index, so at most one flag is pending per content and field. If flags filed before the upgrade would break that index, the migration stops and lists them; close all but one of each and run it again. On MySQL the key column is utf8mb4_bin, so keys compare by case; that collation still ignores trailing spaces (PAD SPACE), which is why moderate strips a flag's field, and why a legacy flag whose field ends in a space can be one of those the migration lists. See CHANGELOG.md for what each beta changes.

Upgrading from 0.x

moderate 1.0 is a ground-up rewrite: the old gem was a profanity validator; 1.0 is a full Trust & Safety system. The one piece of the old API that remains is the validator, now backed by the :wordlist adapter:

validates :body, moderate: true     # still works โ€” the same check as `moderates :body, with: :wordlist, mode: :block`

Everything else is new. There's no automated data migration (0.x stored nothing). See CHANGELOG.md.

Testing

We use Minitest. Run the suite (a dummy Rails app under test/dummy, against SQLite/PostgreSQL/MySQL via Appraisals):

bundle exec rake test
bundle exec rubocop   # CI expects zero offenses

Development

After checking out the repo, run bin/setup to install dependencies. Then run rake test. You can also run bin/console for an interactive prompt.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/moderate. Our code of conduct is: just be nice and make your mom proud of what you do and post online.

License

The gem is available as open source under the terms of the MIT License.

Releases

Packages

Used by

Contributors

Languages