From 81a5feb980168e3662d536f8eafc113d893d21e5 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 03:47:39 +0100
Subject: [PATCH 01/15] Lay v1 foundations: README/docs-first Trust & Safety
rewrite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reframes moderate from a profanity validator (0.1) into a full Trust & Safety gem: report/block/filter/moderate + DSA / App Store / Play compliance. This is the docs-first pass — README, docs/, adaptive install migration, initializer, Gemfile/Appraisals/CI/SimpleCov coherence with the ecosystem. Gem code (models/services/concerns/filters) follows next.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/claude-code-review.yml | 42 ++
.github/workflows/claude.yml | 41 ++
.github/workflows/test.yml | 164 ++++++
.rubocop.yml | 8 +
.simplecov | 37 ++
AGENTS.md | 7 +
Appraisals | 16 +
CLAUDE.md | 7 +
Gemfile | 36 +-
README.md | 371 +++++++++++--
Rakefile | 30 +-
docs/compliance.md | 178 +++++++
docs/configuration.md | 296 +++++++++++
docs/dsa-notice-form.md | 347 +++++++++++++
docs/madmin.md | 490 ++++++++++++++++++
docs/notifications.md | 363 +++++++++++++
lib/generators/moderate/install_generator.rb | 56 ++
.../templates/create_moderate_tables.rb.erb | 274 ++++++++++
.../moderate/templates/initializer.rb | 153 ++++++
lib/moderate/version.rb | 2 +-
moderate.gemspec | 19 +-
test/test_helper.rb | 60 +++
22 files changed, 2959 insertions(+), 38 deletions(-)
create mode 100644 .github/workflows/claude-code-review.yml
create mode 100644 .github/workflows/claude.yml
create mode 100644 .github/workflows/test.yml
create mode 100644 .rubocop.yml
create mode 100644 .simplecov
create mode 100644 AGENTS.md
create mode 100644 Appraisals
create mode 100644 CLAUDE.md
create mode 100644 docs/compliance.md
create mode 100644 docs/configuration.md
create mode 100644 docs/dsa-notice-form.md
create mode 100644 docs/madmin.md
create mode 100644 docs/notifications.md
create mode 100644 lib/generators/moderate/install_generator.rb
create mode 100644 lib/generators/moderate/templates/create_moderate_tables.rb.erb
create mode 100644 lib/generators/moderate/templates/initializer.rb
create mode 100644 test/test_helper.rb
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 0000000..21964b5
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,42 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+
+jobs:
+ claude-review:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ prompt: |
+ REPO: ${{ github.repository }}
+ PR NUMBER: ${{ github.event.pull_request.number }}
+
+ Please review this pull request and provide feedback on:
+ - Code quality and best practices
+ - Potential bugs or issues
+ - Performance considerations
+ - Security concerns
+ - Test coverage
+
+ Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
+
+ Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
+
+ claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 0000000..6d46489
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,41 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..84d6cfa
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,164 @@
+name: Tests
+
+on:
+ pull_request:
+ paths-ignore:
+ - "*.md"
+ - "LICENSE.txt"
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - "*.md"
+ - "LICENSE.txt"
+
+jobs:
+ # Main test suite - tests Ruby versions and Rails compatibility with SQLite
+ sqlite:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.3", "3.4", "4.0"]
+ gemfile:
+ - Gemfile
+ - gemfiles/rails_7.1.gemfile
+ - gemfiles/rails_7.2.gemfile
+ - gemfiles/rails_8.1.gemfile
+
+ env:
+ RAILS_ENV: test
+ BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database and run tests
+ # Exercise the real migration path in SQLite too so dummy/test schema
+ # drift is caught in the default matrix, not only adapter-specific jobs.
+ run: bundle exec rake db:migrate:reset test
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-sqlite-ruby-${{ matrix.ruby_version }}-${{ matrix.gemfile }}
+ path: test/reports/
+ retention-days: 7
+
+ # PostgreSQL compatibility tests
+ postgres:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.4"]
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: moderate_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready -U postgres"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ RAILS_ENV: test
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/moderate_test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database
+ # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb
+ # which has SQLite-specific defaults that fail on PostgreSQL.
+ # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb)
+ run: bundle exec rake db:migrate:reset
+
+ - name: Run tests
+ run: bundle exec rake test
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-postgres-ruby-${{ matrix.ruby_version }}
+ path: test/reports/
+ retention-days: 7
+
+ # MySQL compatibility tests
+ mysql:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.4"]
+
+ services:
+ mysql:
+ image: mysql:8
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: moderate_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h localhost"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ RAILS_ENV: test
+ DATABASE_URL: mysql2://root:root@127.0.0.1:3306/moderate_test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database
+ # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb
+ # which has SQLite-specific defaults that fail on MySQL (JSON column defaults).
+ # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb)
+ run: bundle exec rake db:migrate:reset
+
+ - name: Run tests
+ run: bundle exec rake test
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-mysql-ruby-${{ matrix.ruby_version }}
+ path: test/reports/
+ retention-days: 7
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..ae378d0
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,8 @@
+AllCops:
+ TargetRubyVersion: 3.2
+
+Style/StringLiterals:
+ EnforcedStyle: double_quotes
+
+Style/StringLiteralsInInterpolation:
+ EnforcedStyle: double_quotes
diff --git a/.simplecov b/.simplecov
new file mode 100644
index 0000000..3f3ec4b
--- /dev/null
+++ b/.simplecov
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# SimpleCov configuration file (auto-loaded before test suite)
+# This keeps test_helper.rb clean and follows best practices.
+# Coherent with the rest of the gem ecosystem (usage_credits, pricing_plans, …).
+
+SimpleCov.start do
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
+ formatter SimpleCov::Formatter::SimpleFormatter
+
+ # Don't count the test suite itself toward coverage
+ add_filter "/test/"
+
+ # Track Ruby files in the lib directory (gem source code)
+ track_files "lib/**/*.rb"
+
+ # Enable branch coverage for more detailed metrics
+ enable_coverage :branch
+
+ # Minimum coverage thresholds to prevent coverage regression
+ minimum_coverage line: 80, branch: 75
+
+ # Disambiguate parallel test runs
+ command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"]
+end
+
+# Print coverage summary to terminal after tests complete
+SimpleCov.at_exit do
+ SimpleCov.result.format!
+ puts "\n" + "=" * 60
+ puts "COVERAGE SUMMARY"
+ puts "=" * 60
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
+ puts "Branch Coverage: #{branch_coverage}%"
+ puts "=" * 60
+end
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..472fb8a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,7 @@
+# AGENTS.md
+
+This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
+
+Please read the `README.md` for a full overview of the gem's API and philosophy, and the `docs/` directory (`docs/configuration.md`, `docs/notifications.md`, `docs/compliance.md`, `docs/madmin.md`, `docs/dsa-notice-form.md`) for the detailed integration guides.
+
+This gem is part of a coherent ecosystem (`railsfast`, `goodmail`, `telegrama`, `usage_credits`, `pricing_plans`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Moderate.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice.
diff --git a/Appraisals b/Appraisals
new file mode 100644
index 0000000..392c3b2
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Test the minimum supported Rails version (matches the gemspec floor and the
+# README's "Rails 7.1+ schema" claim — the adaptive migration must work here).
+appraise "rails-7.1" do
+ gem "rails", "~> 7.1.0"
+end
+
+appraise "rails-7.2" do
+ gem "rails", "~> 7.2.0"
+end
+
+# Test the latest Rails version — this is the default/main Gemfile anyway.
+appraise "rails-8.1" do
+ gem "rails", "~> 8.1.0"
+end
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6e9a261
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,7 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+Please read the `README.md` for a full overview of the gem's API and philosophy, and the `docs/` directory (`docs/configuration.md`, `docs/notifications.md`, `docs/compliance.md`, `docs/madmin.md`, `docs/dsa-notice-form.md`) for the detailed integration guides.
+
+This gem is part of a coherent ecosystem (`railsfast`, `goodmail`, `telegrama`, `usage_credits`, `pricing_plans`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Moderate.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice.
diff --git a/Gemfile b/Gemfile
index 1d2662c..f6df1d6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,7 +2,41 @@
source "https://rubygems.org"
-# Specify your gem's dependencies in moderate.gemspec
+# Runtime dependencies are specified in moderate.gemspec
gemspec
+# Build & release tools
gem "rake", "~> 13.0"
+
+group :development do
+ gem "appraisal"
+ gem "web-console"
+
+ # Code quality
+ gem "standard"
+ gem "rubocop", "~> 1.0"
+ gem "rubocop-minitest", "~> 0.35"
+ gem "rubocop-performance", "~> 1.0"
+end
+
+group :test do
+ gem "minitest", "~> 5.0"
+ gem "mocha"
+ gem "simplecov", require: false
+
+ # Database adapters (for multi-database testing)
+ gem "sqlite3"
+ gem "pg"
+ gem "mysql2"
+
+ # Dummy Rails app
+ gem "bootsnap", require: false
+ gem "puma"
+ gem "importmap-rails"
+ gem "sprockets-rails"
+ gem "stimulus-rails"
+ gem "turbo-rails"
+
+ # Fix RDoc version conflict (Ruby 3.4+ ships with 7.0.3)
+ gem "rdoc", ">= 7.0"
+end
diff --git a/README.md b/README.md
index 71f437a..58f3d2b 100644
--- a/README.md
+++ b/README.md
@@ -1,71 +1,384 @@
-# 👮♂️ `moderate` - Block profanities in text fields
+# 🛡️ `moderate` - Trust & Safety for your Rails app (report, block, filter, comply)
-`moderate` is a Ruby gem that moderates user-generated text content by adding a simple validation to block bad words in any text field (profanities, cussing, swearing, obscenity, etc.)
+[](https://badge.fury.io/rb/moderate) [](https://github.com/rameerez/moderate/actions)
-Simply add this to your model:
+> [!TIP]
+> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=moderate)**, 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](https://railsfast.com/?ref=moderate)!
+
+`moderate` gives your Rails app a complete **Trust & Safety** layer — let users **report** abusive content, **block** each other, **filter** objectionable text and images before they're posted, and run a **moderation queue** your admins actually use. It ships **DSA-compliant** (EU Digital Services Act) and aligned with the **Apple App Store** and **Google Play** review guidelines for user-generated content, so you stop leaving store approvals and legal exposure to chance.
+
+It reads like plain English. Make any model reportable:
+
+```ruby
+class Comment < ApplicationRecord
+ reportable
+end
+```
+
+Let any user block another:
```ruby
-validates :text_field, moderate: true
+current_user.block!(@other_user)
+current_user.blocks?(@other_user) # => true
```
-That's it! You're done. `moderate` will work seamlessly with your existing validations and error messages.
+Filter content before it's ever saved — one line:
+
+```ruby
+class Message < ApplicationRecord
+ moderates :body # blocks profanity/slurs/threats by default; or :flag for review
+end
+```
+
+And give admins a real queue to act on:
+
+```ruby
+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-esque gem** instead of scattered, half-finished, store-rejecting DIY code.
+
+> [!NOTE]
+> `moderate` is **UI-agnostic by design**: most Trust & Safety lives in *admin* surfaces, so the gem ships the **primitives** (models, services, helpers, controller concerns) and lets you **bring your own UI**. It plugs into [`madmin`](https://github.com/excid3/madmin) (or any admin) in minutes — see [Admin & moderation queue](#-admin--the-moderation-queue). It also snaps into the rest of the ecosystem: [`goodmail`](https://github.com/rameerez/goodmail) for decision emails, [`telegrama`](https://github.com/rameerez/telegrama) for admin alerts, and [`noticed`](https://github.com/excid3/noticed) for multi-channel notifications — all through one `notify` hook.
+
+---
+
+## Why this gem exists
-> [!WARNING]
-> This gem is under development. It currently only supports a limited set of English profanity words. Word matching is very basic now, and it may be prone to false positives, and false negatives. I use it for very simple things like preventing new submissions if they contain bad words, but the gem can be improved for more complex use cases and sophisticated matching and content moderation. Please consider contributing if you can improve the gem, or have good ideas for additional features.
+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.
-# Why
+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:
-Any text field where users can input text may be a place where bad words can be used. This gem blocks records from being created if they contain bad words, profanity, naughty / obscene words, etc.
+- **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 and OpenAI's free multimodal moderation, or your own.
+- **Moderate** from a queue: remove content, ban users, dismiss, all audited.
+- **Comply**: DSA 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.
-It's good for Rails applications where you need to maintain a clean and respectful environment in comments, posts, or any other user input.
+It works standalone, and gets better with the rest of the ecosystem.
-# How
+## What `moderate` does and doesn't do
-`moderate` currently downloads a list of ~1k English profanity words from the [google-profanity-words](https://github.com/coffee-and-fun/google-profanity-words) repository and caches it in your Rails app's tmp directory.
+**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 (text, image, LLM).
+- 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.
-## Installation
+**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`](https://github.com/rameerez/goodmail) / [`noticed`](https://github.com/excid3/noticed) — `moderate` just emits events).
+- The admin UI chrome (that's [`madmin`](https://github.com/excid3/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).
-Add this line to your application's Gemfile:
+---
+
+## Quickstart
+
+Add the gem:
```ruby
-gem 'moderate'
+gem "moderate"
```
-And then execute:
+Install it (creates the migration + an initializer):
```bash
bundle install
+rails generate moderate:install
+rails db:migrate
```
-Then, just add the `moderate` validation to any model with a text field:
+Tell `moderate` who your users are, and make a model reportable:
```ruby
-validates :text_field, moderate: true
+# config/initializers/moderate.rb
+Moderate.configure do |config|
+ config.user_class = "User"
+end
+```
+
+```ruby
+class User < ApplicationRecord
+ has_moderation # can report, block, be blocked, be banned
+end
+
+class Message < ApplicationRecord
+ reportable # can be reported
+ moderates :body # …and filtered before it's saved
+end
```
-`moderate` will raise an error if a bad word is found in the text field, preventing the record from being saved.
+That's it — you now have reporting, blocking, filtering, and a moderation queue. Everything below is detail.
+
+---
-It works seamlessly with your existing validations and error messages.
+## 🧑🤝🧑 Actors: report & block
-## Configuration
+Add `has_moderation` to your user model (or any model that acts on behalf of a person) — it sits right alongside your other ecosystem macros like `has_credits` / `has_wallets`:
+
+```ruby
+class User < ApplicationRecord
+ has_moderation
+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:
+
+```ruby
+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):
+
+```ruby
+# Hide blocked people from a marketplace / search / inbox:
+Post.where.not(user_id: Moderate.blocked_ids_for(current_user))
+```
+
+**Reporting** content or a person:
+
+```ruby
+current_user.report!(@message, category: :harassment, details: "Won't stop messaging me")
+current_user.report!(@user, category: :impersonation)
+```
+
+`moderate` snapshots the offending content at report time (so evidence survives edits/deletes), infers who's responsible, sends the reporter a receipt, and drops it in the queue.
+
+## 🚩 Reportable content
+
+Declare what can be reported with one `reportable` line — the fields are optional (omit them to report the whole record):
+
+```ruby
+class Listing < ApplicationRecord
+ reportable :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`.)*
+
+You get:
+
+```ruby
+listing.reports # reports filed against this record
+listing.reported? # any open report?
+listing.flagged? # any pending system (auto-filter) flag?
+```
+
+Drop a report link into any view with the helper (it renders nothing if the viewer can't report the content):
+
+```erb
+<%= moderate_report_link(@listing, field: :description) %>
+```
+
+Adding a new reportable type is one `reportable` line — the intake, queue, snapshot, and admin code never change.
+
+## 🧪 Content filtering: `:off` / `:block` / `:flag`
+
+Filtering is one declaration per field, with three modes:
+
+```ruby
+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 # allow the save, queue it for review
+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.
+
+Check content directly anywhere:
+
+```ruby
+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 # => [#
+ <% if flash[:alert].present? %>
+
+ <%= flash[:alert] %>
+
+ <% elsif flash[:notice].present? %>
+
+ <%= flash[:notice] %>
+
+ <% end %>
+
<%# Surface validation errors at the top so the submitter sees why a save failed. %>
<% if @report.respond_to?(:errors) && @report.errors.any? %>
@@ -125,12 +135,13 @@
required: true %>
- <%# Exact URL — "the exact electronic location of that information", Art. 16(2)(b).
+ <%# Exact URLs — "the exact electronic location of that information", Art. 16(2)(b).
Prefilled from ?content_url=… (X-style deep link) but EDITABLE: the notifier
- may correct the link to the specific content they are reporting. %>
+ may correct the link to the specific content they are reporting. One URL per
+ line; the model normalizes into subject_urls and keeps subject_url as first. %>
- <%= form.label :subject_url, t("moderate.notices.fields.content_url", default: "Exact URL of the content") %>
- <%= form.url_field :subject_url, required: true, placeholder: "https://" %>
+ <%= form.label :subject_urls, t("moderate.notices.fields.content_url", default: "Exact URL of the content") %>
+ <%= text_area_tag "notice[subject_urls]", @report.subject_url_list.join("\n"), rows: 3, required: true, placeholder: "https://" %>
<%= t("moderate.notices.hints.content_url", default: "The exact link to the specific content you are reporting.") %>
diff --git a/docs/configuration.md b/docs/configuration.md
index ace3cfa..3e26d50 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -38,7 +38,7 @@ Moderate.configure do |config|
# --- Hooks (all no-op by default) ----------------------------------------
config.audit = ->(event) { … } # record important actions
config.notify = ->(event) { … } # fan out emails / alerts / push
- config.on_block = ->(blocker:, blocked:) { … } # side effects when a block happens
+ config.on_block = ->(blocker:, blocked:, at:) { … } # side effects when a block happens
config.ban_handler = ->(user:, by:, reason:) { … } # how a "ban" is applied in YOUR app
# --- Misc -----------------------------------------------------------------
@@ -250,10 +250,10 @@ content_flagged content_removed
### `on_block` — side effects when a block happens
```ruby
-config.on_block = ->(blocker:, blocked:) { CancelPendingInvites.call(blocker, blocked) }
+config.on_block = ->(blocker:, blocked:, at:) { CancelPendingInvites.call(blocker, blocked, at: at) }
```
-Optional teardown when one user blocks another — cancel a pending invite, leave a shared room, drop a follow. Signature is **keyword args** (`blocker:`, `blocked:`). No-op by default. (A `user_blocked` event also fires through `notify`; use `on_block` for *domain side effects* and `notify` for *messaging*.)
+Optional teardown when one user blocks another — cancel a pending invite, leave a shared room, drop a follow. Signature is **keyword args** (`blocker:`, `blocked:`, `at:`), where `at` is the created block row's timestamp. No-op by default. (A `user_blocked` event also fires through `notify`; use `on_block` for *domain side effects* and `notify` for *messaging*.)
### `ban_handler` — what "banned" means in your app
diff --git a/lib/generators/moderate/templates/initializer.rb b/lib/generators/moderate/templates/initializer.rb
index 021eb4e..5d96d01 100644
--- a/lib/generators/moderate/templates/initializer.rb
+++ b/lib/generators/moderate/templates/initializer.rb
@@ -129,7 +129,7 @@
# Run extra teardown when a block happens (cancel a pending invite, leave a
# shared room, drop a follow…). No-op by default. Signature uses keyword args.
#
- # config.on_block = ->(blocker:, blocked:) { CancelPendingInvites.call(blocker, blocked) }
+ # config.on_block = ->(blocker:, blocked:, at:) { CancelPendingInvites.call(blocker, blocked, at: at) }
# ==========================================================================
# BAN HANDLER — how a "ban" is actually applied in YOUR app
diff --git a/lib/moderate.rb b/lib/moderate.rb
index 31f353a..f1625a9 100644
--- a/lib/moderate.rb
+++ b/lib/moderate.rb
@@ -197,11 +197,12 @@ def audit(event_or_name = nil, **payload)
end
# Run the optional `on_block` side-effect hook (cancel a pending invite, leave a
- # shared room, …). Keyword-arg signature per docs/configuration.md. Kept as a
- # facade method so Moderate::Block has one call site and doesn't reach into
- # config internals. No-op by default.
- def run_on_block(blocker:, blocked:)
- config.on_block.call(blocker: blocker, blocked: blocked)
+ # shared room, …). Keyword-arg signature per docs/configuration.md. `at:` is the
+ # block row's creation time so hosts can apply time-aware teardown without
+ # reaching back into the database. Kept as a facade method so Moderate::Block has
+ # one call site and doesn't reach into config internals. No-op by default.
+ def run_on_block(blocker:, blocked:, at:)
+ config.on_block.call(blocker: blocker, blocked: blocked, at: at)
end
# Apply a ban via the host's `ban_handler` (suspend!, soft-delete, flip a flag,
@@ -209,7 +210,16 @@ def run_on_block(blocker:, blocked:)
# default — the surrounding decision still audits and notifies even if no ban is
# wired, so the action is never silently dropped (docs/configuration.md).
def apply_ban(user:, by:, reason:)
- config.ban_handler.call(user: user, by: by, reason: reason)
+ result = config.ban_handler.call(user: user, by: by, reason: reason)
+ payload = {
+ user_id: user&.id,
+ reason: reason,
+ summary: "user #{user&.id || '(unknown)'} banned"
+ }.compact
+
+ audit(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
+ notify(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
+ result
end
# --- Blocking SSOT --------------------------------------------------------
diff --git a/lib/moderate/configuration.rb b/lib/moderate/configuration.rb
index c38aad7..2386469 100644
--- a/lib/moderate/configuration.rb
+++ b/lib/moderate/configuration.rb
@@ -112,7 +112,7 @@ def initialize
# on_block/ban_handler take keyword args
@audit = ->(_event) {}
@notify = ->(_event) {}
- @on_block = ->(blocker:, blocked:) {}
+ @on_block = ->(blocker:, blocked:, at:) {}
@ban_handler = ->(user:, by:, reason:) {}
# Misc. nil locale ⇒ follow I18n.default_locale at use time.
@@ -170,7 +170,9 @@ def filter_adapter=(value)
# config.register_adapter(:openai, OpenAIModerator.new)
def register_adapter(name, adapter)
key = normalize_name(name)
- raise ArgumentError, "adapter for #{key.inspect} must respond to #classify" unless adapter.respond_to?(:classify)
+ unless adapter.is_a?(String) || adapter.is_a?(Class) || adapter.respond_to?(:classify)
+ raise ArgumentError, "adapter for #{key.inspect} must respond to #classify"
+ end
@adapters[key] = adapter
end
@@ -275,14 +277,19 @@ def validate_block_mode_adapter!(policy)
"can't do — use mode: :flag (allow the write, classify in a job, file a Moderate::Flag)."
end
- # Turn an adapters-registry value into an adapter object. Strings/Classes
- # (the built-ins) are constantized; an object is returned as-is.
+ # Turn an adapters-registry value into an adapter object. Strings/Classes are
+ # constantized and used directly when they expose class-level `classify`
+ # (Moderate::Filters::Base style), otherwise instantiated so a host can
+ # register a plain class whose instances implement `#classify`.
def resolve_adapter(ref)
- case ref
+ adapter = case ref
when String then ref.constantize
when Class then ref
- else ref
+ else
+ return ref
end
+
+ adapter.respond_to?(:classify) ? adapter : adapter.new
end
# Like resolve_adapter but never raises (a built-in whose file isn't loaded yet
diff --git a/lib/moderate/models/block.rb b/lib/moderate/models/block.rb
index aa76617..c6e5e4f 100644
--- a/lib/moderate/models/block.rb
+++ b/lib/moderate/models/block.rb
@@ -83,7 +83,9 @@ def self.block!(blocker:, blocked:)
# Side-effect hook FIRST, inside the transaction: if the host's on_block
# raises, the whole block rolls back rather than leaving a half-applied
# state (edge saved but invite not torn down).
- Moderate.run_on_block(blocker: blocker, blocked: blocked)
+ on_block_payload = audit_payload_from_on_block(
+ Moderate.run_on_block(blocker: blocker, blocked: blocked, at: block.created_at)
+ )
Moderate.audit(
name: :user_blocked,
@@ -93,7 +95,7 @@ def self.block!(blocker:, blocked:)
blocker_id: blocker.id,
blocked_id: blocked.id,
summary: "user #{blocker.id} blocked user #{blocked.id}"
- }
+ }.merge(on_block_payload)
)
end
end
@@ -179,6 +181,16 @@ def self.related_user_ids(user)
private
+ def self.audit_payload_from_on_block(result)
+ return {} if result.nil?
+ return result if result.is_a?(Hash)
+ return result.to_h if result.respond_to?(:to_h)
+
+ {}
+ rescue ArgumentError, TypeError
+ {}
+ end
+
# The DB has a CHECK constraint (`moderate_blocks_no_self_block`) too; this gives
# the friendly validation error before the row ever reaches the database.
def cannot_block_self
diff --git a/lib/moderate/models/concerns/actor.rb b/lib/moderate/models/concerns/actor.rb
index aacd568..0c84769 100644
--- a/lib/moderate/models/concerns/actor.rb
+++ b/lib/moderate/models/concerns/actor.rb
@@ -76,6 +76,7 @@ module Actor
# through, so this stays forward-compatible with the Report model's attributes.
def report!(reportable, category:, details: nil, **attributes)
attributes[:message] = details if details && !attributes.key?(:message)
+ reported_field = attributes.delete(:reported_field) || attributes.delete(:field)
# An in-app reporter attests to good faith IMPLICITLY by choosing to report —
# there's no separate checkbox in the in-app flow (that's the public DSA notice
@@ -85,13 +86,23 @@ def report!(reportable, category:, details: nil, **attributes)
# override by passing `good_faith_confirmed:` in `attributes`.)
attributes[:good_faith_confirmed] = true unless attributes.key?(:good_faith_confirmed)
- Moderate::Report.create!(
+ report = Moderate::Report.new(
reporter: self,
reportable: reportable,
category: category.to_s,
intake_kind: "community",
**attributes
)
+
+ intake = Moderate::Services::IntakeReport.new(
+ report: report,
+ reporter: self,
+ reportable: reportable,
+ reported_field: reported_field
+ )
+ return report if intake.save
+
+ raise ActiveRecord::RecordInvalid, report
end
# --- Blocking -------------------------------------------------------------
diff --git a/lib/moderate/models/flag.rb b/lib/moderate/models/flag.rb
index 7623a58..0ba9fd4 100644
--- a/lib/moderate/models/flag.rb
+++ b/lib/moderate/models/flag.rb
@@ -20,11 +20,10 @@ class Flag < ApplicationRecord
STATUSES = %w[pending actioned dismissed].freeze
- # Where a flag came from. Validated by the `validates :source, inclusion` below —
- # NOT a DB constraint, so the gem can grow the list without a host migration.
- # `external_classifier` covers ANY host-registered remote adapter (OpenAI,
- # Rekognition, Perspective, a self-hosted model) — the gem never hard-codes a
- # specific provider here.
+ # Built-in/generic source names. Host-registered adapter names are also valid
+ # sources (see `.sources` below) because `Moderate.classify` stamps the adapter
+ # name onto the Result when the adapter does not set one explicitly. This is why
+ # a host can register `:openai` or `:image` and see that exact name in the queue.
SOURCES = %w[text_filter image_filter external_classifier manual].freeze
# What the flag WOULD do. `:flag` allowed the write and queued it; `:block`
@@ -63,7 +62,7 @@ class Flag < ApplicationRecord
validates :field, presence: true
validates :status, inclusion: { in: STATUSES }
- validates :source, inclusion: { in: SOURCES }
+ validates :source, inclusion: { in: ->(_flag) { sources } }
validates :mode, inclusion: { in: MODES }
validates :resolution_note, presence: true, if: :closed?
@@ -86,6 +85,10 @@ def self.flag!(flaggable:, field:, owner:, source:, mode:, excerpt:, categories:
)
end
+ def self.sources
+ (SOURCES + Moderate.config.adapters.keys.map(&:to_s)).uniq
+ end
+
def pending?
status == "pending"
end
diff --git a/lib/moderate/models/report.rb b/lib/moderate/models/report.rb
index bc1d089..6c4ebc8 100644
--- a/lib/moderate/models/report.rb
+++ b/lib/moderate/models/report.rb
@@ -209,18 +209,17 @@ def self.report_categories
# --- Signed-GlobalID locators --------------------------------------------
- # Resolve a signed token back into the reported content. `only:` is scoped to
- # the auto-discovered reportable classes (NOT every model), so a forged/replayed
- # token can only ever resolve to a class the host explicitly made reportable —
- # a deliberate allow-list against object-substitution attacks.
+ # Resolve a signed token back into the reported content. Prefer the
+ # auto-discovered registry allow-list when it is already populated, then fall
+ # back to the reportable contract after verifying the SignedGlobalID purpose.
+ # That second path matters in lazy-loaded Rails apps: resolving the token may be
+ # the first thing that constantizes the reportable model, so the registry can be
+ # stale until after GlobalID loads the class.
def self.locate_signed_reportable(token)
return if token.blank?
- GlobalID::Locator.locate_signed(
- token,
- for: SIGNED_GLOBAL_ID_PURPOSE,
- only: Moderate.reportable_classes
- )
+ locate_signed_reportable_from_registry(token) ||
+ locate_signed_reportable_by_contract(token)
end
# Resolve a signed token back into the Report it was minted for (used by the
@@ -286,9 +285,9 @@ def reportable_label
# The snapshotted text of the reported field, asked of the reportable. Returns
# nil when the reportable doesn't expose snapshot text (or there's no record).
def reported_content_text
- return unless reportable.respond_to?(:moderation_snapshot_text)
+ return unless reportable.respond_to?(:moderation_snapshot)
- reportable.moderation_snapshot_text(reported_field)
+ reportable.moderation_snapshot(reported_field)
end
# --- Signed GIDs for emailed links ---------------------------------------
@@ -354,6 +353,24 @@ def automated_processing_used?
private
+ def self.locate_signed_reportable_from_registry(token)
+ classes = Moderate.reportable_classes
+ return if classes.empty?
+
+ record = GlobalID::Locator.locate_signed(token, for: SIGNED_GLOBAL_ID_PURPOSE, only: classes)
+ reportable_contract?(record) ? record : nil
+ end
+
+ def self.locate_signed_reportable_by_contract(token)
+ record = GlobalID::Locator.locate_signed(token, for: SIGNED_GLOBAL_ID_PURPOSE)
+ reportable_contract?(record) ? record : nil
+ end
+
+ def self.reportable_contract?(record)
+ record.respond_to?(:reportable_field_allowed?) &&
+ record.respond_to?(:reported_owner)
+ end
+
# Coalesce the JSON columns to their empty shape so a NULL never reaches a
# NOT-NULL JSON column (the MySQL-no-JSON-default case — see the before_save
# comment). Hash-shaped columns default to {}, the list-shaped one to [].
diff --git a/lib/moderate/services/intake_notice.rb b/lib/moderate/services/intake_notice.rb
index 82ea77d..64ae7fb 100644
--- a/lib/moderate/services/intake_notice.rb
+++ b/lib/moderate/services/intake_notice.rb
@@ -44,6 +44,7 @@ def initialize(attributes:, reporter: nil)
intake_kind: "dsa",
category: @report.category.presence || "illegal_content"
)
+ @report.skip_received_notice = true
@reporter = reporter
end
diff --git a/lib/moderate/services/intake_report.rb b/lib/moderate/services/intake_report.rb
index 02ddb6f..4599483 100644
--- a/lib/moderate/services/intake_report.rb
+++ b/lib/moderate/services/intake_report.rb
@@ -76,6 +76,7 @@ def save
# `acknowledged_at`, set above), but the recipient list is still resolved so
# the host's single notify hook can email AND ping admins from one event.
def deliver_receipt
+ return if report.skip_received_notice
return if recipient_email.blank?
Moderate.notify(
diff --git a/test/dummy/app/adapters/dummy_image_adapter.rb b/test/dummy/app/adapters/dummy_image_adapter.rb
index 68bc0ba..45c877c 100644
--- a/test/dummy/app/adapters/dummy_image_adapter.rb
+++ b/test/dummy/app/adapters/dummy_image_adapter.rb
@@ -17,8 +17,8 @@
# * It flags ANY present image for human review (it ignores the bytes — this is a
# deterministic test double, not a real classifier), producing one Moderate::Flag
# on the (record, field) after commit.
-# * `source` is "image_filter" — one of the four values the install migration's
-# moderate_flags_source_check constraint allows for a flag from an image backend.
+# * It leaves `source` unset so the gem stamps the registered adapter name
+# ("image") onto the persisted Moderate::Flag.
#
# Host-agnostic on purpose: no domain concepts, just "an image was uploaded, queue it
# for review".
@@ -29,7 +29,7 @@ class DummyImageAdapter
# we allow it; any present attachment is flagged for human review with score 1.0
# (a "needs a human" signal, not a probability).
def classify(value)
- return Moderate::Result.allowed(source: "image_filter") if value.blank?
+ return Moderate::Result.allowed if value.blank?
label = Moderate::Label.new(
category: :sexual, # the conservative "needs review" bucket for an unclassified image
@@ -38,7 +38,7 @@ def classify(value)
flagged: true,
input: :image
)
- Moderate::Result.new(allowed: false, labels: [label], source: "image_filter")
+ Moderate::Result.new(allowed: false, labels: [label])
end
# Async: returning false is what makes the spine forbid :block mode and run this
diff --git a/test/dummy/config/initializers/moderate.rb b/test/dummy/config/initializers/moderate.rb
index 663af7b..3eee6c9 100644
--- a/test/dummy/config/initializers/moderate.rb
+++ b/test/dummy/config/initializers/moderate.rb
@@ -61,7 +61,7 @@
config.notify = ->(event) { ModerateTestRecorder.notify(event) }
# ON BLOCK — keyword-arg side-effect hook, captured for assertions.
- config.on_block = ->(blocker:, blocked:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked) }
+ config.on_block = ->(blocker:, blocked:, at:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked, at: at) }
# BAN HANDLER — keyword-arg hook deciding what "banned" means. The recorder just
# captures the request; a test can assert the gem asked for a ban without the
diff --git a/test/dummy/config/support/moderate_test_recorder.rb b/test/dummy/config/support/moderate_test_recorder.rb
index d07e34a..7800438 100644
--- a/test/dummy/config/support/moderate_test_recorder.rb
+++ b/test/dummy/config/support/moderate_test_recorder.rb
@@ -56,9 +56,9 @@ def notify(event)
event # truthy, and lets a test inspect what was "delivered"
end
- # on_block(blocker:, blocked:) — record the pair the gem handed us.
- def on_block(blocker:, blocked:)
- @blocks << { blocker: blocker, blocked: blocked }
+ # on_block(blocker:, blocked:, at:) — record the pair and timestamp the gem handed us.
+ def on_block(blocker:, blocked:, at:)
+ @blocks << { blocker: blocker, blocked: blocked, at: at }
end
# ban_handler(user:, by:, reason:) — record the ban request. We DON'T mutate the
diff --git a/test/integration/blocking_test.rb b/test/integration/blocking_test.rb
index 1528c6e..c425ccd 100644
--- a/test/integration/blocking_test.rb
+++ b/test/integration/blocking_test.rb
@@ -155,7 +155,7 @@ class BlockingTest < ActiveSupport::TestCase
def rewire_hooks(config = Moderate.config)
config.audit = ->(event) { ModerateTestRecorder.audit(event) }
config.notify = ->(event) { ModerateTestRecorder.notify(event) }
- config.on_block = ->(blocker:, blocked:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked) }
+ config.on_block = ->(blocker:, blocked:, at:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked, at: at) }
config.ban_handler = ->(user:, by:, reason:) { ModerateTestRecorder.ban_handler(user: user, by: by, reason: reason) }
config
end
diff --git a/test/integration/content_filtering_test.rb b/test/integration/content_filtering_test.rb
index 8111d91..d982e35 100644
--- a/test/integration/content_filtering_test.rb
+++ b/test/integration/content_filtering_test.rb
@@ -247,10 +247,31 @@ class ContentFilteringTest < ActiveSupport::TestCase
flag = Moderate::Flag.where(field: "image").last
assert_not_nil flag, "an image flag should be filed for the :image field"
- assert_equal "image_filter", flag.source
+ assert_equal "image", flag.source
assert_equal comment, flag.flaggable
end
+ test "a registered adapter can be a string class name and records the adapter name as the flag source" do
+ Moderate.configure do |config|
+ rewire_hooks(config)
+ config.register_adapter :string_image, "DummyImageAdapter"
+ config.filter "Comment", :image, with: :string_image, mode: :flag
+ end
+
+ comment = Comment.new(user: @user, body: CLEAN)
+ comment.image.attach(
+ io: StringIO.new("not really an image"),
+ filename: "avatar.png",
+ content_type: "image/png"
+ )
+
+ assert_difference -> { Moderate::Flag.count }, 1 do
+ assert comment.save
+ end
+
+ assert_equal "string_image", Moderate::Flag.where(field: "image").last.source
+ end
+
private
# Re-point the four host hooks at the in-memory recorder after `reset!` wiped them.
@@ -259,7 +280,7 @@ class ContentFilteringTest < ActiveSupport::TestCase
def rewire_hooks(config = Moderate.config)
config.audit = ->(event) { ModerateTestRecorder.audit(event) }
config.notify = ->(event) { ModerateTestRecorder.notify(event) }
- config.on_block = ->(blocker:, blocked:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked) }
+ config.on_block = ->(blocker:, blocked:, at:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked, at: at) }
config.ban_handler = ->(user:, by:, reason:) { ModerateTestRecorder.ban_handler(user: user, by: by, reason: reason) }
config
end
diff --git a/test/integration/notice_form_test.rb b/test/integration/notice_form_test.rb
index 07e0f2c..ec0ea50 100644
--- a/test/integration/notice_form_test.rb
+++ b/test/integration/notice_form_test.rb
@@ -67,9 +67,9 @@ class NoticeFormTest < ActionDispatch::IntegrationTest
}
assert_response :success
- # subject_url (Art. 16(2)(b)) prefilled into an EDITABLE url field — no readonly.
- assert_select "input[name=?]", "notice[subject_url]" do |els|
- assert_equal "https://example.test/p/123", els.first["value"]
+ # subject_urls (Art. 16(2)(b)) prefilled into an EDITABLE text area — no readonly.
+ assert_select "textarea[name=?]", "notice[subject_urls]" do |els|
+ assert_equal "https://example.test/p/123", els.first.text
assert_nil els.first["readonly"], "the reported-content URL must stay editable"
end
# content_type prefilled as the selected option.
@@ -94,9 +94,9 @@ class NoticeFormTest < ActionDispatch::IntegrationTest
get NEW
assert_response :success
assert_select "form"
- # The URL field renders, with no prefilled value (Rails omits a nil value attr).
- assert_select "input[name=?]", "notice[subject_url]" do |els|
- assert els.first["value"].to_s.empty?, "subject_url should be blank with no query string"
+ # The URL field renders, with no prefilled value.
+ assert_select "textarea[name=?]", "notice[subject_urls]" do |els|
+ assert els.first.text.empty?, "subject_urls should be blank with no query string"
end
end
@@ -117,7 +117,7 @@ class NoticeFormTest < ActionDispatch::IntegrationTest
end
# The reported-content fields are STILL editable even when identity is locked.
- assert_select "input[name=?]", "notice[subject_url]" do |els|
+ assert_select "textarea[name=?]", "notice[subject_urls]" do |els|
assert_nil els.first["readonly"], "the reported-content URL stays editable"
end
end
@@ -171,10 +171,28 @@ class NoticeFormTest < ActionDispatch::IntegrationTest
receipts = ModerateTestRecorder.notifications_named(:notice_received)
assert_equal 1, receipts.size
assert_equal "notifier@example.com", receipts.first.recipients.first.email
+ assert_empty ModerateTestRecorder.notifications_named(:report_received)
# And the shared intake path audited it.
assert_equal 1, ModerateTestRecorder.audits_named(:report_received).size
end
+ test "POST create accepts newline-separated exact URLs" do
+ params = well_formed_notice_params.except(:subject_url).merge(
+ subject_urls: "https://example.test/illegal\nhttps://example.test/also-illegal"
+ )
+
+ assert_difference -> { Moderate::Report.count }, 1 do
+ post CREATE, params: { notice: params }
+ end
+
+ report = Moderate::Report.last
+ assert_equal "https://example.test/illegal", report.subject_url
+ assert_equal [
+ "https://example.test/illegal",
+ "https://example.test/also-illegal"
+ ], report.subject_urls
+ end
+
test "POST create with an invalid notice re-renders the form 422 and creates nothing" do
assert_no_difference -> { Moderate::Report.count } do
post CREATE, params: { notice: well_formed_notice_params.merge(legal_reason: "") }
diff --git a/test/integration/reporting_test.rb b/test/integration/reporting_test.rb
index 365b009..eef95d1 100644
--- a/test/integration/reporting_test.rb
+++ b/test/integration/reporting_test.rb
@@ -107,6 +107,9 @@ def current_user = test_viewer
assert_equal @author, report.reported_user
assert report.open?, "a fresh report awaits a decision"
assert_includes Moderate::Report.pending, report
+ assert_predicate report.acknowledged_at, :present?
+ assert_equal 1, ModerateTestRecorder.audits_named(:report_received).size
+ assert_equal 1, ModerateTestRecorder.notifications_named(:report_received).size
end
test "report! against a field not on the reportable whitelist is rejected" do
@@ -162,7 +165,7 @@ def view_context(viewer:)
def rewire_hooks(config = Moderate.config)
config.audit = ->(event) { ModerateTestRecorder.audit(event) }
config.notify = ->(event) { ModerateTestRecorder.notify(event) }
- config.on_block = ->(blocker:, blocked:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked) }
+ config.on_block = ->(blocker:, blocked:, at:) { ModerateTestRecorder.on_block(blocker: blocker, blocked: blocked, at: at) }
config.ban_handler = ->(user:, by:, reason:) { ModerateTestRecorder.ban_handler(user: user, by: by, reason: reason) }
config
end
diff --git a/test/models/moderate/block_test.rb b/test/models/moderate/block_test.rb
index a179384..405789c 100644
--- a/test/models/moderate/block_test.rb
+++ b/test/models/moderate/block_test.rb
@@ -50,12 +50,33 @@ class BlockTest < ActiveSupport::TestCase
test "block! fires the on_block side-effect hook once on creation" do
captured = []
- Moderate.config.on_block = ->(blocker:, blocked:) { captured << [blocker, blocked] }
+ Moderate.config.on_block = ->(blocker:, blocked:, at:) { captured << [blocker, blocked, at] }
Moderate::Block.block!(blocker: @blocker, blocked: @blocked)
Moderate::Block.block!(blocker: @blocker, blocked: @blocked)
- assert_equal [[@blocker, @blocked]], captured
+ assert_equal 1, captured.length
+ assert_equal @blocker, captured.first[0]
+ assert_equal @blocked, captured.first[1]
+ assert_instance_of ActiveSupport::TimeWithZone, captured.first[2]
+ end
+
+ test "block! merges hash-like on_block metadata into the audit payload" do
+ audits = []
+ Moderate.config.audit = ->(event) { audits << event }
+ Moderate.config.on_block = ->(blocker:, blocked:, at:) {
+ { side_effect: "cancelled_invites", blocker_id_from_hook: blocker.id, blocked_id_from_hook: blocked.id, at_from_hook: at.iso8601 }
+ }
+
+ Moderate::Block.block!(blocker: @blocker, blocked: @blocked)
+
+ audit = audits.find { |event| event.name == :user_blocked }
+ assert_equal "cancelled_invites", audit.payload[:side_effect]
+ assert_equal @blocker.id, audit.payload[:blocker_id]
+ assert_equal @blocked.id, audit.payload[:blocked_id]
+ assert_equal @blocker.id, audit.payload[:blocker_id_from_hook]
+ assert_equal @blocked.id, audit.payload[:blocked_id_from_hook]
+ assert audit.payload[:at_from_hook].present?
end
test "unblock! removes the edge and returns true; missing edge returns false" do
diff --git a/test/models/moderate/flag_test.rb b/test/models/moderate/flag_test.rb
index e59ac3a..b2263d9 100644
--- a/test/models/moderate/flag_test.rb
+++ b/test/models/moderate/flag_test.rb
@@ -130,7 +130,7 @@ class FlagTest < ActiveSupport::TestCase
end
flag = Moderate::Flag.pending.where(flaggable: comment, field: "image").last
- assert_equal "image_filter", flag.source
+ assert_equal "image", flag.source
assert_equal comment.user, flag.owner # owner inferred from reported_owner
end
diff --git a/test/models/moderate/report_test.rb b/test/models/moderate/report_test.rb
index 51afbc7..f9f106b 100644
--- a/test/models/moderate/report_test.rb
+++ b/test/models/moderate/report_test.rb
@@ -43,6 +43,7 @@ class ReportTest < ActiveSupport::TestCase
assert_equal comment.id, snapshot["reportable_id"]
assert_equal "body", snapshot["reported_field"]
assert_equal author.id, snapshot["reported_user_id"]
+ assert_equal "a perfectly ordinary comment", snapshot["content_text"]
assert snapshot["captured_at"].present?
# A fresh report has not yet acknowledged receipt (DSA Art. 16(4) stamp).
@@ -183,6 +184,18 @@ class ReportTest < ActiveSupport::TestCase
assert_nil Moderate::Report.locate_signed_reportable(nil)
end
+ test "signed reportable lookup falls back to the reportable contract when the registry is stale" do
+ user = create_user
+ token = user.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)
+ registry = Moderate.send(:reportable_registry)
+ original_registry = registry.dup
+ registry.delete(user.class.name)
+
+ assert_equal user, Moderate::Report.locate_signed_reportable(token)
+ ensure
+ registry.replace(original_registry) if registry && original_registry
+ end
+
test "automated_processing_used? is true when an auto-flag exists for the same target+field" do
author = create_user
comment = Comment.create!(user: author, body: "clean text")
diff --git a/test/services/moderate/resolve_report_test.rb b/test/services/moderate/resolve_report_test.rb
index c897c8f..57420c5 100644
--- a/test/services/moderate/resolve_report_test.rb
+++ b/test/services/moderate/resolve_report_test.rb
@@ -67,6 +67,9 @@ class ResolveReportTest < ActiveSupport::TestCase
# affected-user statement of reasons (Art. 17).
assert_equal 1, ModerateTestRecorder.notifications_named(:report_decision).size
assert_equal 1, ModerateTestRecorder.notifications_named(:affected_user_decision).size
+ assert_equal 1, ModerateTestRecorder.notifications_named(:user_banned).size
+ assert_equal author, ModerateTestRecorder.notifications_named(:user_banned).first.subject
+ assert_equal moderator, ModerateTestRecorder.notifications_named(:user_banned).first.actor
# Delivered ⇒ the legal-communication timestamps were stamped.
assert_predicate report.decision_notified_at, :present?
From 0fb3a60479e39b7278b67121dda0466073e9ba1e Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 17:25:39 +0100
Subject: [PATCH 05/15] Own legal appeals and refine actor macro
---
README.md | 6 +-
.../moderate/appeals_controller.rb | 173 ++++++++++++++++++
.../moderate/application_controller.rb | 8 +-
.../moderate/notices_controller.rb | 2 +-
.../transparency_reports_controller.rb | 34 ++++
app/views/moderate/appeals/new.html.erb | 78 ++++++++
.../_summary_card.html.erb | 20 ++
.../transparency_reports/show.html.erb | 52 ++++++
config/routes.rb | 2 +
docs/compliance.md | 4 +-
docs/configuration.md | 8 +-
docs/dsa-notice-form.md | 12 +-
lib/moderate.rb | 2 +-
lib/moderate/configuration.rb | 33 +++-
lib/moderate/engine.rb | 2 +-
lib/moderate/macros.rb | 14 +-
lib/moderate/models/concerns/actor.rb | 7 +-
lib/moderate/models/concerns/reportable.rb | 4 +-
test/dummy/app/models/user.rb | 4 +-
test/dummy/config/initializers/moderate.rb | 2 +-
test/integration/appeal_form_test.rb | 116 ++++++++++++
test/integration/transparency_report_test.rb | 26 +++
test/macros_test.rb | 8 +-
test/models/moderate/report_test.rb | 2 +-
24 files changed, 573 insertions(+), 46 deletions(-)
create mode 100644 app/controllers/moderate/appeals_controller.rb
create mode 100644 app/controllers/moderate/transparency_reports_controller.rb
create mode 100644 app/views/moderate/appeals/new.html.erb
create mode 100644 app/views/moderate/transparency_reports/_summary_card.html.erb
create mode 100644 app/views/moderate/transparency_reports/show.html.erb
create mode 100644 test/integration/appeal_form_test.rb
create mode 100644 test/integration/transparency_report_test.rb
diff --git a/README.md b/README.md
index 719c388..76cb26d 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ end
```ruby
class User < ApplicationRecord
- has_moderation # can report, block, be blocked, be banned
+ participates_in_moderation # can report, block, be blocked, be banned
end
class Message < ApplicationRecord
@@ -118,11 +118,11 @@ That's it — you now have reporting, blocking, filtering, and a moderation queu
## 🧑🤝🧑 Actors: report & block
-Add `has_moderation` to your user model (or any model that acts on behalf of a person) — it sits right alongside your other ecosystem macros like `has_credits` / `has_wallets`:
+Add `participates_in_moderation` to your user model (or any model that acts on behalf of a person):
```ruby
class User < ApplicationRecord
- has_moderation
+ participates_in_moderation
end
```
diff --git a/app/controllers/moderate/appeals_controller.rb b/app/controllers/moderate/appeals_controller.rb
new file mode 100644
index 0000000..9c28dd7
--- /dev/null
+++ b/app/controllers/moderate/appeals_controller.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+module Moderate
+ # Public DSA Art. 20 internal complaint form for moderation decisions.
+ class AppealsController < Moderate::ApplicationController
+ before_action :enforce_appeal_enabled!
+ before_action :throttle_appeals!, only: :create
+ before_action :verify_human!, only: :create
+
+ def new
+ @report = Moderate::Report.locate_signed_appeal_report(params[:token])
+ return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
+
+ @appeal = Moderate::Appeal.new(
+ report: @report,
+ appellant: current_appellant,
+ appellant_name: current_appellant_name,
+ appellant_email: current_appellant_email,
+ source: appeal_source_for(@report, params[:source])
+ )
+ end
+
+ def create
+ @report = Moderate::Report.locate_signed_appeal_report(params[:token])
+ return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
+
+ attributes = appeal_params
+ attributes[:source] = appeal_source_for(@report, attributes[:source])
+
+ intake = Moderate::Services::IntakeAppeal.new(
+ appeal: Moderate::Appeal.new(attributes),
+ report: @report,
+ appellant: current_appellant
+ )
+ @appeal = intake.appeal
+
+ if intake.save
+ redirect_to appeal_return_path,
+ notice: t("moderate.appeals.received", default: "Appeal received. A human reviewer will assess the decision."),
+ status: :see_other
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def appeal_params
+ params.require(:appeal).permit(:appellant_name, :appellant_email, :source, :reason)
+ end
+
+ def appeal_source_for(report, requested_source)
+ return "affected_user" if current_appellant.present? && current_appellant.id == report.reported_user_id
+
+ source = requested_source.to_s.squish
+ return "notifier" if source == "affected_user" && report.reported_user_id.blank?
+
+ Moderate::Appeal::SOURCES.include?(source) ? source : "notifier"
+ end
+
+ def current_appellant
+ return @current_appellant if defined?(@current_appellant)
+
+ @current_appellant =
+ if respond_to?(:current_user, true)
+ current_user
+ end
+ rescue StandardError
+ @current_appellant = nil
+ end
+
+ def current_appellant_name
+ current_appellant&.try(:display_name) || current_appellant&.try(:name)
+ end
+
+ def current_appellant_email
+ current_appellant&.try(:email)
+ end
+
+ def appeal_return_path
+ path = Moderate.config.appeal_return_path
+ path = path.call(self) if path.respond_to?(:call)
+ path.presence || "/"
+ end
+
+ def enforce_appeal_enabled!
+ return if Moderate.config.appeal_form_enabled
+
+ raise ActionController::RoutingError, "Moderate appeal form is disabled (config.appeal_form_enabled = false)"
+ end
+
+ def throttle_appeals!
+ limit = Moderate.config.appeal_rate_limit
+ return if limit == false || limit.nil?
+
+ max = limit.fetch(:max, 10)
+ within = limit.fetch(:within, 60).to_i
+
+ key = "moderate:appeal_rate:#{request.remote_ip}"
+ count = rate_limit_increment(key, expires_in: within)
+
+ render_rate_limited if count > max
+ end
+
+ def rate_limit_increment(key, expires_in:)
+ store = Rails.cache
+ return 0 unless store
+
+ current = store.read(key).to_i
+ store.write(key, current + 1, expires_in: expires_in) if current.zero?
+ store.increment(key) || (current + 1)
+ rescue StandardError
+ 0
+ end
+
+ def render_rate_limited
+ flash.now[:alert] = t("moderate.appeals.rate_limited", default: "Too many appeals from this address. Please try again later.")
+ rebuild_appeal_from_params
+ render :new, status: :too_many_requests
+ end
+
+ def verify_human!
+ if turnstile_available?
+ verify_turnstile!
+ else
+ run_appeal_guard!
+ end
+ end
+
+ def turnstile_available?
+ defined?(::RailsCloudflareTurnstile) && respond_to?(:validate_cloudflare_turnstile, true)
+ end
+
+ def verify_turnstile!
+ validate_cloudflare_turnstile
+ rescue ::RailsCloudflareTurnstile::Forbidden
+ render_captcha_failed
+ end
+
+ def run_appeal_guard!
+ guard = Moderate.config.appeal_guard
+ return unless guard.respond_to?(:call)
+ return if safe_call_guard(guard)
+
+ render_captcha_failed
+ end
+
+ def safe_call_guard(guard)
+ guard.call(self) ? true : false
+ rescue StandardError
+ false
+ end
+
+ def render_captcha_failed
+ flash.now[:alert] = t("moderate.appeals.captcha_failed", default: "We couldn't verify you're human. Please try the check again.")
+ rebuild_appeal_from_params
+ render :new, status: :unprocessable_entity
+ end
+
+ def rebuild_appeal_from_params
+ @report ||= Moderate::Report.locate_signed_appeal_report(params[:token])
+ @appeal = Moderate::Appeal.new(appeal_params_safe)
+ @appeal.report = @report if @report
+ @appeal.source = appeal_source_for(@report, @appeal.source) if @report
+ end
+
+ def appeal_params_safe
+ appeal_params.to_h
+ rescue ActionController::ParameterMissing
+ {}
+ end
+ end
+end
diff --git a/app/controllers/moderate/application_controller.rb b/app/controllers/moderate/application_controller.rb
index f7dd961..2197560 100644
--- a/app/controllers/moderate/application_controller.rb
+++ b/app/controllers/moderate/application_controller.rb
@@ -5,15 +5,15 @@ module Moderate
# form). It is NOT used by the host's in-app report/admin controllers — those are
# BYOUI and inherit from the host's own ApplicationController.
#
- # The parent class is INDIRECTED through `config.notice_parent_controller`
+ # The parent class is INDIRECTED through `config.parent_controller`
# (default `"::ActionController::Base"`), exactly the `config.parent_controller`
# trick Devise and `api_keys` use. Why indirect instead of just inheriting from
# `ActionController::Base`?
# - On an API-only app there is no `ActionController::Base` view stack by
# default; defaulting to it (and pulling in the view modules below) keeps the
# HTML notice form working even there.
- # - A host that wants the public form to sit inside its own site chrome points
- # `config.notice_parent_controller` at its own base controller and inherits
+ # - A host that wants the public forms to sit inside its own site chrome points
+ # `config.parent_controller` at its own base controller and inherits
# its layout, locale-setting, current_user, etc. — without us hard-coding any
# of that.
#
@@ -23,7 +23,7 @@ module Moderate
# this file. The configured value is a STRING constantized lazily, consistent with
# the rest of the gem's "store class names as strings" rule.
parent = begin
- Moderate.config.notice_parent_controller.to_s.constantize
+ Moderate.config.parent_controller.to_s.constantize
rescue NameError
# Defensive fallback: if the configured parent isn't loadable (typo, or an
# API-only app without ActionController::Base required yet), fall back to the
diff --git a/app/controllers/moderate/notices_controller.rb b/app/controllers/moderate/notices_controller.rb
index b4ffe87..f8eb147 100644
--- a/app/controllers/moderate/notices_controller.rb
+++ b/app/controllers/moderate/notices_controller.rb
@@ -116,7 +116,7 @@ def content_prefill
# Identity prefill from the signed-in user, when one exists. We detect Devise (or
# any auth that exposes `current_user`) WITHOUT a hard dependency: the engine's
# base controller may or may not define `current_user` depending on the host's
- # `notice_parent_controller`. `respond_to?` keeps the public/anonymous form
+ # `parent_controller`. `respond_to?` keeps the public/anonymous form
# working when nobody is logged in (the overwhelmingly common case for Art. 16).
# We read name/email via `try` so the host's user class only needs whichever it
# actually has. These keys are what the view LOCKS.
diff --git a/app/controllers/moderate/transparency_reports_controller.rb b/app/controllers/moderate/transparency_reports_controller.rb
new file mode 100644
index 0000000..0caec60
--- /dev/null
+++ b/app/controllers/moderate/transparency_reports_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Moderate
+ # Public aggregate transparency report for moderation intake, decisions, appeals,
+ # and automated flags.
+ class TransparencyReportsController < Moderate::ApplicationController
+ def show
+ @period_start = 1.year.ago.beginning_of_day
+ @period_end = Time.current
+ reports = Moderate::Report.where(created_at: @period_start..@period_end)
+ appeals = Moderate::Appeal.where(created_at: @period_start..@period_end)
+ flags = Moderate::Flag.where(created_at: @period_start..@period_end)
+
+ @summary = {
+ notices_by_intake: reports.group(:intake_kind).count,
+ dsa_notices_by_legal_reason: reports.where(intake_kind: "dsa").group(:legal_reason).count,
+ actions_by_basis: reports.where.not(resolved_at: nil).group(:resolution_basis).count,
+ automated_flags_by_source: flags.group(:source).count,
+ appeals_by_status: appeals.group(:status).count,
+ median_notice_action_seconds: median_seconds(reports.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)),
+ median_appeal_action_seconds: median_seconds(appeals.where.not(resolved_at: nil).pluck(:created_at, :resolved_at))
+ }
+ end
+
+ private
+
+ def median_seconds(pairs)
+ values = pairs.filter_map { |created_at, resolved_at| resolved_at && created_at ? (resolved_at - created_at).to_i : nil }.sort
+ return 0 if values.empty?
+
+ values[values.length / 2]
+ end
+ end
+end
diff --git a/app/views/moderate/appeals/new.html.erb b/app/views/moderate/appeals/new.html.erb
new file mode 100644
index 0000000..c0d0c30
--- /dev/null
+++ b/app/views/moderate/appeals/new.html.erb
@@ -0,0 +1,78 @@
+
+
+
<%= t("moderate.appeals.deadline", default: "You can request a free internal review until %{deadline}. A person will review it.", deadline: l(@report.appeal_deadline_at, format: :long)) %>
diff --git a/config/routes.rb b/config/routes.rb
index 175d1bd..3fdb267 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -26,6 +26,8 @@
# on-record proof of receipt is the report's `acknowledged_at`, and the human-
# facing confirmation goes out through the `notice_received` notify hook).
resources :notices, only: %i[new create]
+ resources :appeals, only: %i[new create]
+ resource :transparency, only: :show, controller: "transparency_reports"
# The engine root redirects to the form, so mounting the engine makes its mount
# point itself a sensible landing spot (and a fine place to host the DSA Art.
diff --git a/docs/compliance.md b/docs/compliance.md
index 6e7fec8..f324603 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -133,7 +133,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is `reportable` can be reported. | **[gem]** | `test/models/reportable_test.rb` |
-| In-app **reporting** of objectionable **users**. | A user model with `has_moderation` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
+| In-app **reporting** of objectionable **users**. | A user model with `participates_in_moderation` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/block_test.rb` |
| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/models/blocked_ids_scope_test.rb` |
| A method to **moderate UGC** (a real review surface, not just intake). | `Moderate::Report.pending` / `Moderate::Flag.pending` give admins the queue; `resolve!`/`dismiss!`/`remove_content`/`ban_user` are the audited actions. (BYOUI — you bind these to your admin; see [`docs/madmin.md`](madmin.md).) | **[gem + you]** | `test/services/resolve_test.rb` |
@@ -141,7 +141,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Users **accept terms / acceptable-use** before contributing UGC. | This is your signup/terms gate — `moderate` doesn't own it — but, as with Apple, your acceptable-use policy should enumerate the **community-report categories** so the terms and the report buttons describe the same prohibited behavior. | **[you]** | manual: terms acceptance in your onboarding |
> [!NOTE]
-> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_moderation` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
+> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `participates_in_moderation` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
---
diff --git a/docs/configuration.md b/docs/configuration.md
index 3e26d50..c33b8b7 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -46,7 +46,7 @@ Moderate.configure do |config|
end
```
-The DSA notice-form options (`notice_form_enabled`, `notice_rate_limit`, `notice_turnstile_*`, `notice_parent_controller`, `notice_captcha_verifier`) are documented in their own guide — see [The DSA notice form](dsa-notice-form.md#configuration-reference-notice-form). They're omitted here to keep this focused on the core T&S surface.
+The public legal-form options (`parent_controller`, `notice_form_enabled`, `notice_rate_limit`, `notice_guard`, `appeal_form_enabled`, `appeal_rate_limit`, `appeal_guard`, `appeal_return_path`) are documented in their own guide — see [The DSA notice form](dsa-notice-form.md#configuration-reference-notice-form). They're omitted here to keep this focused on the core T&S surface.
---
@@ -62,7 +62,7 @@ The model that **acts** in your Trust & Safety system: it reports, it blocks, it
```ruby
class User < ApplicationRecord
- has_moderation # the sugar (preferred) — gains report!/block!/blocks?/blocked_with?…
+ participates_in_moderation # gains report!/block!/blocks?/blocked_with?…
# include Moderate::Actor # the documented, exactly-equivalent include form
end
```
@@ -283,11 +283,11 @@ Config sets defaults; the model macros consume them. The two halves of the API:
| In the model | In the initializer | What it controls |
| --- | --- | --- |
-| `has_moderation` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
+| `participates_in_moderation` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
| `reportable :title, :description` (or `include Moderate::Reportable`) | — (auto-discovered) | Which content is reportable, and which fields |
| `moderates :body, with:, mode:` | `config.default_filter_mode`, `config.filter_adapter`, `config.filter "…"` | Pre-publication filtering per field |
-Both sugar macros have an exactly-equivalent `include` form for include-purists — `has_moderation` ⇔ `include Moderate::Actor`, `reportable` ⇔ `include Moderate::Reportable`. The sugar is preferred because it reads as plain English alongside the rest of your stack (`has_credits`, `has_wallets`, `has_api_keys`), but they compile to the same thing.
+Both sugar macros have an exactly-equivalent `include` form for include-purists — `participates_in_moderation` ⇔ `include Moderate::Actor`, `reportable` ⇔ `include Moderate::Reportable`. They compile to the same thing.
---
diff --git a/docs/dsa-notice-form.md b/docs/dsa-notice-form.md
index eb96080..1588030 100644
--- a/docs/dsa-notice-form.md
+++ b/docs/dsa-notice-form.md
@@ -58,7 +58,7 @@ That third point is the **Devise pattern**, and we copy it on purpose because ev
| `mount` is implicit via `devise_for` | `mount Moderate::Engine => "/"` |
| Views ship inside the gem | Views ship inside the engine (`app/views/moderate/notices/`) |
| `rails g devise:views` copies them to your app | `rails g moderate:views` copies them to your app |
-| `config.parent_controller` | `config.notice_parent_controller` |
+| `config.parent_controller` | `config.parent_controller` |
| Rails view lookup prefers `app/views` over the gem | identical — an ejected view **shadows** the bundled one, zero config |
| Works untouched if you never eject | Works untouched if you never eject |
@@ -207,7 +207,7 @@ module Moderate
end
```
-`Moderate::ApplicationController` (the engine's base) inherits from `config.notice_parent_controller.constantize` (default `"::ActionController::Base"` so it works even on API-only apps, with `protect_from_forgery` applied when available) — exactly the `config.parent_controller` indirection Devise uses, so you can point it at your own base controller to inherit your layout, locale-setting, and `current_user`.
+`Moderate::ApplicationController` (the engine's base) inherits from `config.parent_controller.constantize` (default `"::ActionController::Base"` so it works even on API-only apps, with `protect_from_forgery` applied when available) — exactly the `parent_controller` indirection Devise uses, so you can point it at your own base controller to inherit your layout, locale-setting, and `current_user`.
#### The bot gate (auto-integrates `rails_cloudflare_turnstile`)
@@ -278,7 +278,7 @@ Moderate::Report::DSA_LEGAL_REASONS
### Out of the box
-The gem ships the templates inside the engine, under `app/views/moderate/`. They render with no CSS framework assumed, themable via CSS custom properties (`:root { --moderate-* }`), and pull every label/hint through `I18n` (`moderate.notices.*`) so you can translate without touching markup. The layout inherits nothing from your app by default; point `config.notice_parent_controller` at your own base controller (and give it a `layout`) if you'd rather the form sit inside your site chrome.
+The gem ships the templates inside the engine, under `app/views/moderate/`. They render with no CSS framework assumed, themable via CSS custom properties (`:root { --moderate-* }`), and pull every label/hint through `I18n` (`moderate.notices.*`) so you can translate without touching markup. The layout inherits nothing from your app by default; point `config.parent_controller` at your own base controller (and give it a `layout`) if you'd rather the forms sit inside your site chrome.
### Ejecting the views
@@ -326,7 +326,11 @@ And if you don't serve EU users at all? Skip both. Reporting, blocking, and filt
```ruby
Moderate.configure do |config|
config.notice_form_enabled = true # mount-able engine on/off (default: true)
- config.notice_parent_controller = "::ActionController::Base" # like Devise's config.parent_controller
+ config.parent_controller = "::ActionController::Base" # like Devise's config.parent_controller
+ config.appeal_form_enabled = true
+ config.appeal_rate_limit = { max: 10, within: 1.minute }
+ config.appeal_guard = ->(controller) { true }
+ config.appeal_return_path = "/"
config.notice_rate_limit = { max: 5, within: 1.hour } # per-IP throttle, or false to disable
# Bot gate:
diff --git a/lib/moderate.rb b/lib/moderate.rb
index f1625a9..7e09b8a 100644
--- a/lib/moderate.rb
+++ b/lib/moderate.rb
@@ -17,7 +17,7 @@
require_relative "moderate/event"
require_relative "moderate/configuration"
-# The class-level DSL (has_moderation / reportable / moderates). Required here,
+# The class-level DSL (participates_in_moderation / reportable / moderates). Required here,
# eagerly, because the engine's `moderate.active_record` initializer does
# `extend Moderate::Macros` inside an `on_load(:active_record)` block — the constant
# must already be defined by the time that hook fires. It's a plain module that only
diff --git a/lib/moderate/configuration.rb b/lib/moderate/configuration.rb
index 2386469..72bd516 100644
--- a/lib/moderate/configuration.rb
+++ b/lib/moderate/configuration.rb
@@ -70,9 +70,12 @@ def flag? = mode == :flag
# receives the controller and returns truthy to allow the POST. nil/no-op ⇒ the
# form just works (the default). When the host installs `rails_cloudflare_turnstile`,
# the controller auto-uses Turnstile instead and this proc is bypassed.
- attr_accessor :notice_form_enabled, :notice_parent_controller, :notice_rate_limit,
+ attr_reader :parent_controller
+ attr_accessor :notice_form_enabled, :notice_rate_limit,
:notice_turnstile_site_key, :notice_turnstile_secret_key,
- :notice_captcha_verifier, :notice_guard, :signed_gid_purposes
+ :notice_captcha_verifier, :notice_guard,
+ :appeal_form_enabled, :appeal_rate_limit, :appeal_guard, :appeal_return_path,
+ :signed_gid_purposes
def initialize
# Identity. "User" is the overwhelmingly common case; the host overrides it
@@ -118,13 +121,15 @@ def initialize
# Misc. nil locale ⇒ follow I18n.default_locale at use time.
@locale = nil
+ # Engine controller defaults. The parent controller defaults to a stock base so
+ # the engine works even on API-only apps — the same `parent_controller`
+ # indirection Devise and api_keys use.
+ @parent_controller = "::ActionController::Base"
+
# DSA notice-form defaults (see docs/dsa-notice-form.md). The form is on by
# default; both bot-gates no-op when their keys are blank; the rate limit is a
- # sane per-IP throttle. The parent controller defaults to a stock base so the
- # engine works even on API-only apps — the same `parent_controller`
- # indirection Devise and api_keys use.
+ # sane per-IP throttle.
@notice_form_enabled = true
- @notice_parent_controller = "::ActionController::Base"
@notice_rate_limit = { max: 5, within: 3600 } # 1.hour, expressed in seconds to avoid an ActiveSupport dependency here
@notice_turnstile_site_key = nil
@notice_turnstile_secret_key = nil
@@ -132,9 +137,25 @@ def initialize
# Gem-absent fallback bot gate. nil ⇒ no extra gate (the form just works); the
# controller only consults it when rails_cloudflare_turnstile is NOT installed.
@notice_guard = nil
+
+ # DSA internal complaint / appeal form defaults. Same shape as the notice
+ # form: public route, optional bot gate, runtime rate limit, and a redirect
+ # target the host can choose.
+ @appeal_form_enabled = true
+ @appeal_rate_limit = { max: 10, within: 60 }
+ @appeal_guard = nil
+ @appeal_return_path = "/"
+
@signed_gid_purposes = %i[appeal confirm_notice unsubscribe]
end
+ def parent_controller=(value)
+ name = value.is_a?(Class) ? value.name : value.to_s
+ raise ArgumentError, "parent_controller can't be blank" if name.strip.empty?
+
+ @parent_controller = name
+ end
+
# --- Validating setters ---------------------------------------------------
# user_class is stored as a String (constantized lazily by `Moderate.user_class`).
diff --git a/lib/moderate/engine.rb b/lib/moderate/engine.rb
index 853727c..a96eeba 100644
--- a/lib/moderate/engine.rb
+++ b/lib/moderate/engine.rb
@@ -115,7 +115,7 @@ class Engine < ::Rails::Engine
#
# `ActiveSupport.on_load(:active_record)` defers until ActiveRecord::Base is
# actually defined, so we never force-load AR at boot and we play nicely with
- # the host's load order. Once it fires, every model gains `has_moderation`,
+ # the host's load order. Once it fires, every model gains `participates_in_moderation`,
# `reportable`, and `moderates` as class methods (the macros that lazily
# include Moderate::Actor / Moderate::Reportable / Moderate::ContentFilterable).
#
diff --git a/lib/moderate/macros.rb b/lib/moderate/macros.rb
index 56d01ef..5baf590 100644
--- a/lib/moderate/macros.rb
+++ b/lib/moderate/macros.rb
@@ -12,17 +12,17 @@
#
# This is safe because the macro methods below only REFERENCE the concern constants
# inside their bodies (`include Moderate::Actor`), which run when a host model calls
-# `has_moderation`/`reportable`/`moderates` — long after boot, when the autoloader
+# `participates_in_moderation`/`reportable`/`moderates` — long after boot, when the autoloader
# is fully wired. So Zeitwerk autoloads each concern lazily on first use.
module Moderate
# The class-level DSL the gem adds to every ActiveRecord model.
#
# The engine does `ActiveSupport.on_load(:active_record) { extend Moderate::Macros }`,
- # so `has_moderation`, `reportable`, and `moderates` become class methods on
+ # so `participates_in_moderation`, `reportable`, and `moderates` become class methods on
# ActiveRecord::Base — readable plain-English declarations that sit alongside the
# rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`):
#
- # class User < ApplicationRecord; has_moderation; end
+ # class User < ApplicationRecord; participates_in_moderation; end
# class Listing < ApplicationRecord; reportable :title, :description; end
# class Message < ApplicationRecord; moderates :body, mode: :flag; end
#
@@ -32,13 +32,13 @@ module Moderate
# forward to that concern's declaration method. All behavior lives in the
# concerns, never here.
module Macros
- # `has_moderation` — make this model an ACTOR (and, since a user is itself
- # reportable, a reportable too): report!/block!/unblock!/blocks?/blocked_with?,
- # the block & report associations, and the be-banned target.
+ # `participates_in_moderation` — make this model an ACTOR (and, since a user is
+ # usually itself reportable, a reportable too): report!/block!/unblock!/blocks?/
+ # blocked_with?, the block & report associations, and the be-banned target.
#
# Equivalent to `include Moderate::Actor`. Idempotent: re-declaring (or both
# macro + explicit include) won't double-include.
- def has_moderation
+ def participates_in_moderation
include Moderate::Actor unless include?(Moderate::Actor)
end
diff --git a/lib/moderate/models/concerns/actor.rb b/lib/moderate/models/concerns/actor.rb
index 0c84769..e12bac1 100644
--- a/lib/moderate/models/concerns/actor.rb
+++ b/lib/moderate/models/concerns/actor.rb
@@ -3,7 +3,8 @@
module Moderate
# The "person who acts" in the Trust & Safety system: the model that reports
# other content, blocks other actors, gets reported, gets banned. Backs the
- # `has_moderation` macro (and its documented equivalent, `include Moderate::Actor`).
+ # `participates_in_moderation` macro (and its documented equivalent,
+ # `include Moderate::Actor`).
#
# This is the one model the gem treats as the actor/identity, configured via
# `config.user_class`. There's normally exactly one such model per app ("User",
@@ -19,7 +20,7 @@ module Moderate
module Actor
extend ActiveSupport::Concern
- # A user is also reportable. Pulling Reportable in here means `has_moderation`
+ # A user is also reportable. Pulling Reportable in here means `participates_in_moderation`
# alone gives you both halves (act AND be-acted-on) without a second macro.
include Moderate::Reportable
@@ -59,7 +60,7 @@ module Actor
# --- Reporting ------------------------------------------------------------
# File a report from this actor against a piece of content (or another actor —
- # a user with `has_moderation` is itself reportable).
+ # a user with `participates_in_moderation` is itself reportable).
#
# current_user.report!(@message, category: :harassment, details: "...")
# current_user.report!(@other_user, category: :impersonation)
diff --git a/lib/moderate/models/concerns/reportable.rb b/lib/moderate/models/concerns/reportable.rb
index 7e8c44b..bd2c4e5 100644
--- a/lib/moderate/models/concerns/reportable.rb
+++ b/lib/moderate/models/concerns/reportable.rb
@@ -97,7 +97,7 @@ def reportable_field_allowed?(field)
# NO default: a model that can be reported MUST tell the gem who's behind it,
# because guessing wrong here means notifying or banning the wrong person.
# We raise a NotImplementedError naming the class so the omission is loud at
- # the first report, not silent. (A `User` model with `has_moderation` is itself
+ # the first report, not silent. (A `User` model with `participates_in_moderation` is itself
# reportable and returns `self` — see Moderate::Actor.)
def reported_owner
raise NotImplementedError,
@@ -148,7 +148,7 @@ def remove_reported_field!(_field)
#
# The `moderate_report_link` helper renders nothing when this is false, and the
# report controller redirects. Hosts can override for richer rules. (A User with
- # `has_moderation` overrides this in Moderate::Actor to compare ids directly,
+ # `participates_in_moderation` overrides this in Moderate::Actor to compare ids directly,
# since a user IS its own owner.)
def report_visible_to?(viewer, field:)
return false unless reportable_field_allowed?(field)
diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb
index f68be69..9d2f763 100644
--- a/test/dummy/app/models/user.rb
+++ b/test/dummy/app/models/user.rb
@@ -2,7 +2,7 @@
# The dummy host's ACTOR model — the `config.user_class`.
#
-# `has_moderation` (the macro the engine adds to ActiveRecord::Base) makes a User
+# `participates_in_moderation` (the macro the engine adds to ActiveRecord::Base) makes a User
# able to report, block/unblock, be reported, and be banned; because Actor pulls in
# Reportable, a User is ALSO reportable (Apple 1.2 / Google Play UGC both require
# reporting AND blocking *users*, not just content). `reportable :name` narrows the
@@ -10,7 +10,7 @@
# whitelist (a report naming `:name` is allowed; one naming an undeclared field is
# rejected by Moderate::Report's reportable_field_must_be_allowed validation).
class User < ApplicationRecord
- has_moderation
+ participates_in_moderation
reportable :name
# The initializer declares `config.filter "User", :name, mode: :flag`, so a User
diff --git a/test/dummy/config/initializers/moderate.rb b/test/dummy/config/initializers/moderate.rb
index 3eee6c9..0fb050d 100644
--- a/test/dummy/config/initializers/moderate.rb
+++ b/test/dummy/config/initializers/moderate.rb
@@ -25,7 +25,7 @@
# shared setup is the natural place to re-point the hooks at ModerateTestRecorder).
# This file documents the canonical wiring; the suite mirrors it post-reset.
Moderate.configure do |config|
- # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / has_moderation).
+ # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / participates_in_moderation).
# Stored as a string, constantized lazily, so this works even though User isn't
# loaded yet at boot.
config.user_class = "User"
diff --git a/test/integration/appeal_form_test.rb b/test/integration/appeal_form_test.rb
new file mode 100644
index 0000000..898b395
--- /dev/null
+++ b/test/integration/appeal_form_test.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class AppealFormTest < ActionDispatch::IntegrationTest
+ NEW = "/trust/appeals/new"
+ CREATE = "/trust/appeals"
+
+ setup do
+ @forgery_was = ActionController::Base.allow_forgery_protection
+ ActionController::Base.allow_forgery_protection = false
+
+ Moderate.configure do |config|
+ config.audit = ->(event) { ModerateTestRecorder.audit(event) }
+ config.notify = ->(event) { ModerateTestRecorder.notify(event) }
+ config.appeal_rate_limit = false
+ config.appeal_return_path = "/"
+ end
+ ModerateTestRecorder.clear
+ end
+
+ teardown do
+ ActionController::Base.allow_forgery_protection = @forgery_was
+ end
+
+ test "GET new renders an appeal form for a signed moderation decision" do
+ report = closed_report(reported_user: User.create!(name: "Affected", email: "affected@example.com"))
+ token = report.signed_appeal_gid
+
+ get NEW, params: { token: token, source: "affected_user" }
+
+ assert_response :success
+ assert_select "form[action=?]", "#{CREATE}?token=#{CGI.escape(token)}"
+ assert_select "input[name=?][value=?]", "appeal[source]", "affected_user"
+ end
+
+ test "POST create saves, notifies, audits, and redirects to the configured return path" do
+ report = closed_report
+ token = report.signed_appeal_gid
+
+ assert_difference -> { Moderate::Appeal.count }, 1 do
+ post CREATE, params: {
+ token: token,
+ appeal: {
+ appellant_name: "Appealing Person",
+ appellant_email: "appeal@example.com",
+ source: "notifier",
+ reason: "Please review this decision."
+ }
+ }
+ end
+
+ assert_redirected_to "/"
+ appeal = Moderate::Appeal.last
+ assert_equal report, appeal.report
+ assert_equal "notifier", appeal.source
+ assert_equal 1, ModerateTestRecorder.notifications_named(:appeal_received).size
+ assert_equal 1, ModerateTestRecorder.audits_named(:appeal_received).size
+ end
+
+ test "affected_user source falls back to notifier when the report has no affected user" do
+ report = closed_report
+
+ post CREATE, params: {
+ token: report.signed_appeal_gid,
+ appeal: {
+ appellant_name: "Appealing Person",
+ appellant_email: "appeal@example.com",
+ source: "affected_user",
+ reason: "Please review this decision."
+ }
+ }
+
+ assert_equal "notifier", Moderate::Appeal.last.source
+ end
+
+ test "a configured appeal guard can block create" do
+ Moderate.config.appeal_guard = ->(_controller) { false }
+ report = closed_report
+
+ assert_no_difference -> { Moderate::Appeal.count } do
+ post CREATE, params: {
+ token: report.signed_appeal_gid,
+ appeal: {
+ appellant_name: "Appealing Person",
+ appellant_email: "appeal@example.com",
+ source: "notifier",
+ reason: "Please review this decision."
+ }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ assert_match(/verify/i, flash[:alert].to_s)
+ end
+
+ private
+
+ def closed_report(reported_user: nil)
+ Moderate::Report.create!(
+ reported_user: reported_user,
+ notifier_name: "Notice Sender",
+ notifier_email: "notice@example.com",
+ category: "illegal_content",
+ subject_url: "https://example.test/content/1",
+ message: "Please review",
+ good_faith_confirmed: true,
+ status: "dismissed",
+ resolution_note: "No violation",
+ resolution_basis: "no_violation",
+ decision_visibility: "no_restriction",
+ resolved_at: Time.current,
+ appeal_deadline_at: 1.month.from_now
+ )
+ end
+end
diff --git a/test/integration/transparency_report_test.rb b/test/integration/transparency_report_test.rb
new file mode 100644
index 0000000..03e24b4
--- /dev/null
+++ b/test/integration/transparency_report_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class TransparencyReportTest < ActionDispatch::IntegrationTest
+ test "public transparency report renders aggregate moderation counters" do
+ Moderate::Report.create!(
+ notifier_name: "Notice Sender",
+ notifier_email: "notice@example.com",
+ category: "illegal_content",
+ intake_kind: "dsa",
+ legal_reason: "public_security",
+ legal_country_code: "ES",
+ content_type: "listing",
+ subject_url: "https://example.test/content/1",
+ message: "Please review",
+ good_faith_confirmed: true
+ )
+
+ get "/trust/transparency"
+
+ assert_response :success
+ assert_includes response.body, "Moderation transparency"
+ assert_includes response.body, "Public security"
+ end
+end
diff --git a/test/macros_test.rb b/test/macros_test.rb
index 810a049..bd81390 100644
--- a/test/macros_test.rb
+++ b/test/macros_test.rb
@@ -3,7 +3,7 @@
require "test_helper"
# Tests for the class-level DSL the engine adds to every ActiveRecord model:
-# `has_moderation`, `reportable`, and `moderates` (Moderate::Macros, extended onto
+# `participates_in_moderation`, `reportable`, and `moderates` (Moderate::Macros, extended onto
# ActiveRecord::Base via `ActiveSupport.on_load(:active_record)`).
#
# Two angles:
@@ -14,15 +14,15 @@
# assertion sees the policy the macro just created rather than a boot-time one
# that reset! wiped.
class MacrosTest < ActiveSupport::TestCase
- # --- has_moderation (Actor + Reportable) ----------------------------------
+ # --- participates_in_moderation (Actor + Reportable) ----------------------
- test "has_moderation includes Moderate::Actor (and Reportable, since a user is reportable)" do
+ test "participates_in_moderation includes Moderate::Actor (and Reportable, since a user is reportable)" do
assert User.include?(Moderate::Actor)
# Actor pulls in Reportable — Apple 1.2 / Play UGC require reporting USERS too.
assert User.include?(Moderate::Reportable)
end
- test "has_moderation gives an actor the report!/block! surface" do
+ test "participates_in_moderation gives an actor the report!/block! surface" do
actor = create_user
%i[report! block! unblock! blocks? blocked_by? blocked_with?].each do |method|
assert_respond_to actor, method, "expected actor to respond to ##{method}"
diff --git a/test/models/moderate/report_test.rb b/test/models/moderate/report_test.rb
index f9f106b..cea014a 100644
--- a/test/models/moderate/report_test.rb
+++ b/test/models/moderate/report_test.rb
@@ -51,7 +51,7 @@ class ReportTest < ActiveSupport::TestCase
end
test "reportable classes are auto-discovered from the reportable macro" do
- # User (has_moderation → reportable) and Comment (reportable :body) both
+ # User (participates_in_moderation -> reportable) and Comment (reportable :body) both
# self-registered on inclusion — no manual registry.
assert_includes Moderate.reportable_classes, User
assert_includes Moderate.reportable_classes, Comment
From 855b9bc9c1d8c9aee24c6b9a5a6008c0bdbd1d0e Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 17:41:57 +0100
Subject: [PATCH 06/15] Harden moderation primitives and verification hooks
---
README.md | 8 ++---
.../moderate/appeals_controller.rb | 17 ++++++++++
.../moderate/notices_controller.rb | 17 ++++++++++
app/views/moderate/appeals/new.html.erb | 2 +-
app/views/moderate/notices/new.html.erb | 2 +-
docs/compliance.md | 4 +--
docs/configuration.md | 8 ++---
docs/dsa-notice-form.md | 21 +++++++++++++
.../moderate/templates/initializer.rb | 17 ++++++++++
lib/moderate.rb | 2 +-
lib/moderate/configuration.rb | 14 ++++++---
lib/moderate/engine.rb | 2 +-
lib/moderate/macros.rb | 10 +++---
lib/moderate/models/block.rb | 5 +--
lib/moderate/models/concerns/actor.rb | 10 +++---
lib/moderate/models/concerns/reportable.rb | 4 +--
lib/moderate/models/flag.rb | 2 +-
lib/moderate/models/report.rb | 13 +++++++-
test/configuration_test.rb | 31 +++++++++++++++++++
test/dummy/app/models/user.rb | 4 +--
test/dummy/config/initializers/moderate.rb | 2 +-
test/integration/appeal_form_test.rb | 24 ++++++++++++++
test/integration/notice_form_test.rb | 18 +++++++++++
test/integration/reporting_test.rb | 13 ++++++++
test/macros_test.rb | 8 ++---
test/models/moderate/block_test.rb | 14 +++++++--
test/models/moderate/flag_test.rb | 16 ++++++++++
test/models/moderate/report_test.rb | 24 +++++++++++---
28 files changed, 265 insertions(+), 47 deletions(-)
create mode 100644 test/configuration_test.rb
diff --git a/README.md b/README.md
index 76cb26d..4b27a61 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ end
```ruby
class User < ApplicationRecord
- participates_in_moderation # can report, block, be blocked, be banned
+ has_moderation_capabilities # can report, block, be blocked, be banned
end
class Message < ApplicationRecord
@@ -118,11 +118,11 @@ That's it — you now have reporting, blocking, filtering, and a moderation queu
## 🧑🤝🧑 Actors: report & block
-Add `participates_in_moderation` to your user model (or any model that acts on behalf of a person):
+Add `has_moderation_capabilities` to your user model (or any model that acts on behalf of a person):
```ruby
class User < ApplicationRecord
- participates_in_moderation
+ has_moderation_capabilities
end
```
@@ -326,7 +326,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
`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`](https://github.com/instrumentl/rails-cloudflare-turnstile) when present (falling back to a `config.notice_guard` proc). See [`docs/dsa-notice-form.md`](docs/dsa-notice-form.md).
+- **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`](https://github.com/instrumentl/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`](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).
diff --git a/app/controllers/moderate/appeals_controller.rb b/app/controllers/moderate/appeals_controller.rb
index 9c28dd7..c8313c3 100644
--- a/app/controllers/moderate/appeals_controller.rb
+++ b/app/controllers/moderate/appeals_controller.rb
@@ -3,6 +3,8 @@
module Moderate
# Public DSA Art. 20 internal complaint form for moderation decisions.
class AppealsController < Moderate::ApplicationController
+ helper_method :turnstile_widget_required?
+
before_action :enforce_appeal_enabled!
before_action :throttle_appeals!, only: :create
before_action :verify_human!, only: :create
@@ -120,6 +122,8 @@ def render_rate_limited
end
def verify_human!
+ return if human_verification_skipped?
+
if turnstile_available?
verify_turnstile!
else
@@ -127,6 +131,19 @@ def verify_human!
end
end
+ def turnstile_widget_required?
+ turnstile_available? && !human_verification_skipped?
+ end
+
+ def human_verification_skipped?
+ predicate = Moderate.config.appeal_human_verification_skip_if
+ return false unless predicate.respond_to?(:call)
+
+ predicate.call(self) ? true : false
+ rescue StandardError
+ false
+ end
+
def turnstile_available?
defined?(::RailsCloudflareTurnstile) && respond_to?(:validate_cloudflare_turnstile, true)
end
diff --git a/app/controllers/moderate/notices_controller.rb b/app/controllers/moderate/notices_controller.rb
index f8eb147..8604afc 100644
--- a/app/controllers/moderate/notices_controller.rb
+++ b/app/controllers/moderate/notices_controller.rb
@@ -22,6 +22,8 @@ module Moderate
# `intake_kind: "dsa"`, sharing the same queue, snapshot, appeal window, and Art.
# 24 transparency counters as an in-app report. One queue, two front doors.
class NoticesController < Moderate::ApplicationController
+ helper_method :turnstile_widget_required?
+
# Hard kill-switch: if a host sets `config.notice_form_enabled = false`, the
# whole engine surface 404s. Lets an app mount the engine but disable the form
# (e.g. it doesn't serve EU users) without un-mounting routes.
@@ -290,6 +292,8 @@ def render_rate_limited
# Detection is via `defined?`/`respond_to?` with NO hard dependency in the
# gemspec, decided at REQUEST time so the wiring auto-adapts to the bundle.
def verify_human!
+ return if human_verification_skipped?
+
if turnstile_available?
verify_turnstile!
else
@@ -297,6 +301,19 @@ def verify_human!
end
end
+ def turnstile_widget_required?
+ turnstile_available? && !human_verification_skipped?
+ end
+
+ def human_verification_skipped?
+ predicate = Moderate.config.notice_human_verification_skip_if
+ return false unless predicate.respond_to?(:call)
+
+ predicate.call(self) ? true : false
+ rescue StandardError
+ false
+ end
+
# True when the gem is loaded AND its controller helper is mixed in here, so a
# half-loaded/renamed gem can never put us on a path whose method doesn't exist.
def turnstile_available?
diff --git a/app/views/moderate/appeals/new.html.erb b/app/views/moderate/appeals/new.html.erb
index c0d0c30..76f95a3 100644
--- a/app/views/moderate/appeals/new.html.erb
+++ b/app/views/moderate/appeals/new.html.erb
@@ -60,7 +60,7 @@
placeholder: t("moderate.appeals.placeholders.reason", default: "Explain why you believe the decision should be reviewed and include any useful context.") %>
- <% if defined?(::RailsCloudflareTurnstile) && respond_to?(:cloudflare_turnstile) %>
+ <% if turnstile_widget_required? && respond_to?(:cloudflare_turnstile) %>
<%= cloudflare_turnstile_script_tag if respond_to?(:cloudflare_turnstile_script_tag) %>
<%= cloudflare_turnstile %>
diff --git a/app/views/moderate/notices/new.html.erb b/app/views/moderate/notices/new.html.erb
index e01fe2d..eb080ff 100644
--- a/app/views/moderate/notices/new.html.erb
+++ b/app/views/moderate/notices/new.html.erb
@@ -241,7 +241,7 @@
`config.notice_guard` (no-op by default), so the form just works.
https://github.com/instrumentl/rails-cloudflare-turnstile (README)
%>
- <% if defined?(::RailsCloudflareTurnstile) && respond_to?(:cloudflare_turnstile) %>
+ <% if turnstile_widget_required? && respond_to?(:cloudflare_turnstile) %>
<%= cloudflare_turnstile_script_tag if respond_to?(:cloudflare_turnstile_script_tag) %>
<%= cloudflare_turnstile %>
diff --git a/docs/compliance.md b/docs/compliance.md
index f324603..1e71120 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -133,7 +133,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is `reportable` can be reported. | **[gem]** | `test/models/reportable_test.rb` |
-| In-app **reporting** of objectionable **users**. | A user model with `participates_in_moderation` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
+| In-app **reporting** of objectionable **users**. | A user model with `has_moderation_capabilities` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/block_test.rb` |
| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/models/blocked_ids_scope_test.rb` |
| A method to **moderate UGC** (a real review surface, not just intake). | `Moderate::Report.pending` / `Moderate::Flag.pending` give admins the queue; `resolve!`/`dismiss!`/`remove_content`/`ban_user` are the audited actions. (BYOUI — you bind these to your admin; see [`docs/madmin.md`](madmin.md).) | **[gem + you]** | `test/services/resolve_test.rb` |
@@ -141,7 +141,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Users **accept terms / acceptable-use** before contributing UGC. | This is your signup/terms gate — `moderate` doesn't own it — but, as with Apple, your acceptable-use policy should enumerate the **community-report categories** so the terms and the report buttons describe the same prohibited behavior. | **[you]** | manual: terms acceptance in your onboarding |
> [!NOTE]
-> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `participates_in_moderation` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
+> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_moderation_capabilities` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
---
diff --git a/docs/configuration.md b/docs/configuration.md
index c33b8b7..e475dd3 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -46,7 +46,7 @@ Moderate.configure do |config|
end
```
-The public legal-form options (`parent_controller`, `notice_form_enabled`, `notice_rate_limit`, `notice_guard`, `appeal_form_enabled`, `appeal_rate_limit`, `appeal_guard`, `appeal_return_path`) are documented in their own guide — see [The DSA notice form](dsa-notice-form.md#configuration-reference-notice-form). They're omitted here to keep this focused on the core T&S surface.
+The public legal-form options (`parent_controller`, `notice_form_enabled`, `notice_rate_limit`, `notice_guard`, `notice_human_verification_skip_if`, `appeal_form_enabled`, `appeal_rate_limit`, `appeal_guard`, `appeal_human_verification_skip_if`, `appeal_return_path`) are documented in their own guide — see [The DSA notice form](dsa-notice-form.md#configuration-reference-notice-form). They're omitted here to keep this focused on the core T&S surface.
---
@@ -62,7 +62,7 @@ The model that **acts** in your Trust & Safety system: it reports, it blocks, it
```ruby
class User < ApplicationRecord
- participates_in_moderation # gains report!/block!/blocks?/blocked_with?…
+ has_moderation_capabilities # gains report!/block!/blocks?/blocked_with?…
# include Moderate::Actor # the documented, exactly-equivalent include form
end
```
@@ -283,11 +283,11 @@ Config sets defaults; the model macros consume them. The two halves of the API:
| In the model | In the initializer | What it controls |
| --- | --- | --- |
-| `participates_in_moderation` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
+| `has_moderation_capabilities` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
| `reportable :title, :description` (or `include Moderate::Reportable`) | — (auto-discovered) | Which content is reportable, and which fields |
| `moderates :body, with:, mode:` | `config.default_filter_mode`, `config.filter_adapter`, `config.filter "…"` | Pre-publication filtering per field |
-Both sugar macros have an exactly-equivalent `include` form for include-purists — `participates_in_moderation` ⇔ `include Moderate::Actor`, `reportable` ⇔ `include Moderate::Reportable`. They compile to the same thing.
+Both sugar macros have an exactly-equivalent `include` form for include-purists — `has_moderation_capabilities` ⇔ `include Moderate::Actor`, `reportable` ⇔ `include Moderate::Reportable`. They compile to the same thing.
---
diff --git a/docs/dsa-notice-form.md b/docs/dsa-notice-form.md
index 1588030..ac68e2d 100644
--- a/docs/dsa-notice-form.md
+++ b/docs/dsa-notice-form.md
@@ -229,6 +229,25 @@ A public, unauthenticated form is a spam magnet. `moderate` ships a **single, re
We default to recommending Turnstile (privacy-friendly, free, the RailsFast house default), but you're never locked in: the guard is just a lambda.
+Some clients cannot render a browser challenge at all (for example a native shell
+posting through your authenticated web session, or an edge layer that has already
+verified the request). For those cases, keep the browser gate on for normal traffic
+and skip it per request:
+
+```ruby
+config.notice_human_verification_skip_if = ->(controller) {
+ controller.request.user_agent.to_s.match?(/Hotwire Native/i)
+}
+
+config.appeal_human_verification_skip_if = ->(controller) {
+ controller.request.user_agent.to_s.match?(/Hotwire Native/i)
+}
+```
+
+The proc receives the controller and defaults to `nil` (never skip). If it raises,
+the gem treats that as "do not skip" so a broken predicate cannot accidentally turn
+off the public form's bot gate.
+
#### The rate-limit hook
On Rails 7.2+ you could use the built-in `rate_limit` API; `moderate` instead implements a tiny per-IP, cache-backed counter (`Rails.cache`) as a `before_action`, so it honors your **runtime** `config.notice_rate_limit` (the class-level macro is evaluated at class load, before your initializer has run). Configure it once:
@@ -330,6 +349,7 @@ Moderate.configure do |config|
config.appeal_form_enabled = true
config.appeal_rate_limit = { max: 10, within: 1.minute }
config.appeal_guard = ->(controller) { true }
+ config.appeal_human_verification_skip_if = ->(controller) { false }
config.appeal_return_path = "/"
config.notice_rate_limit = { max: 5, within: 1.hour } # per-IP throttle, or false to disable
@@ -337,6 +357,7 @@ Moderate.configure do |config|
# - Install `rails_cloudflare_turnstile` and it AUTO-integrates (widget + verify), no config here.
# - Otherwise set a guard proc (no-op by default) to use hCaptcha / reCAPTCHA / your own check:
config.notice_guard = ->(controller) { true } # ->(controller) { boolean }
+ config.notice_human_verification_skip_if = ->(controller) { false }
end
```
diff --git a/lib/generators/moderate/templates/initializer.rb b/lib/generators/moderate/templates/initializer.rb
index 5d96d01..7464347 100644
--- a/lib/generators/moderate/templates/initializer.rb
+++ b/lib/generators/moderate/templates/initializer.rb
@@ -142,6 +142,23 @@
#
# config.ban_handler = ->(user:, by:, reason:) { user.suspend!(reason: reason) }
+ # ==========================================================================
+ # PUBLIC FORM HUMAN VERIFICATION — optional per-request skips
+ # ==========================================================================
+ #
+ # The notice and appeal forms auto-use rails_cloudflare_turnstile when present,
+ # otherwise they fall back to notice_guard / appeal_guard. If one of your clients
+ # cannot render a browser challenge (for example a native shell or an edge-verified
+ # request), skip the human-verification gate for that request only. Nil by default.
+ #
+ # config.notice_human_verification_skip_if = ->(controller) {
+ # controller.request.user_agent.to_s.match?(/Hotwire Native/i)
+ # }
+ #
+ # config.appeal_human_verification_skip_if = ->(controller) {
+ # controller.request.user_agent.to_s.match?(/Hotwire Native/i)
+ # }
+
# ==========================================================================
# SIGNED LINKS — purposes for the signed Global IDs in emails & notices
# ==========================================================================
diff --git a/lib/moderate.rb b/lib/moderate.rb
index 7e09b8a..8c8eb7f 100644
--- a/lib/moderate.rb
+++ b/lib/moderate.rb
@@ -17,7 +17,7 @@
require_relative "moderate/event"
require_relative "moderate/configuration"
-# The class-level DSL (participates_in_moderation / reportable / moderates). Required here,
+# The class-level DSL (has_moderation_capabilities / reportable / moderates). Required here,
# eagerly, because the engine's `moderate.active_record` initializer does
# `extend Moderate::Macros` inside an `on_load(:active_record)` block — the constant
# must already be defined by the time that hook fires. It's a plain module that only
diff --git a/lib/moderate/configuration.rb b/lib/moderate/configuration.rb
index 72bd516..2d9b9bb 100644
--- a/lib/moderate/configuration.rb
+++ b/lib/moderate/configuration.rb
@@ -73,8 +73,9 @@ def flag? = mode == :flag
attr_reader :parent_controller
attr_accessor :notice_form_enabled, :notice_rate_limit,
:notice_turnstile_site_key, :notice_turnstile_secret_key,
- :notice_captcha_verifier, :notice_guard,
- :appeal_form_enabled, :appeal_rate_limit, :appeal_guard, :appeal_return_path,
+ :notice_captcha_verifier, :notice_guard, :notice_human_verification_skip_if,
+ :appeal_form_enabled, :appeal_rate_limit, :appeal_guard,
+ :appeal_human_verification_skip_if, :appeal_return_path,
:signed_gid_purposes
def initialize
@@ -137,6 +138,7 @@ def initialize
# Gem-absent fallback bot gate. nil ⇒ no extra gate (the form just works); the
# controller only consults it when rails_cloudflare_turnstile is NOT installed.
@notice_guard = nil
+ @notice_human_verification_skip_if = nil
# DSA internal complaint / appeal form defaults. Same shape as the notice
# form: public route, optional bot gate, runtime rate limit, and a redirect
@@ -144,6 +146,7 @@ def initialize
@appeal_form_enabled = true
@appeal_rate_limit = { max: 10, within: 60 }
@appeal_guard = nil
+ @appeal_human_verification_skip_if = nil
@appeal_return_path = "/"
@signed_gid_purposes = %i[appeal confirm_notice unsubscribe]
@@ -203,10 +206,13 @@ def register_adapter(name, adapter)
# callers decide whether that's an error (the validator/classify path raises a
# helpful message; see Moderate.classify).
def adapter_for(name)
- ref = @adapters[normalize_name(name)]
+ key = normalize_name(name)
+ ref = @adapters[key]
return nil if ref.nil?
- resolve_adapter(ref)
+ resolve_adapter(ref).tap do |adapter|
+ @adapters[key] = adapter unless adapter.equal?(ref)
+ end
end
def adapter_registered?(name)
diff --git a/lib/moderate/engine.rb b/lib/moderate/engine.rb
index a96eeba..c63c599 100644
--- a/lib/moderate/engine.rb
+++ b/lib/moderate/engine.rb
@@ -115,7 +115,7 @@ class Engine < ::Rails::Engine
#
# `ActiveSupport.on_load(:active_record)` defers until ActiveRecord::Base is
# actually defined, so we never force-load AR at boot and we play nicely with
- # the host's load order. Once it fires, every model gains `participates_in_moderation`,
+ # the host's load order. Once it fires, every model gains `has_moderation_capabilities`,
# `reportable`, and `moderates` as class methods (the macros that lazily
# include Moderate::Actor / Moderate::Reportable / Moderate::ContentFilterable).
#
diff --git a/lib/moderate/macros.rb b/lib/moderate/macros.rb
index 5baf590..27ed382 100644
--- a/lib/moderate/macros.rb
+++ b/lib/moderate/macros.rb
@@ -12,17 +12,17 @@
#
# This is safe because the macro methods below only REFERENCE the concern constants
# inside their bodies (`include Moderate::Actor`), which run when a host model calls
-# `participates_in_moderation`/`reportable`/`moderates` — long after boot, when the autoloader
+# `has_moderation_capabilities`/`reportable`/`moderates` — long after boot, when the autoloader
# is fully wired. So Zeitwerk autoloads each concern lazily on first use.
module Moderate
# The class-level DSL the gem adds to every ActiveRecord model.
#
# The engine does `ActiveSupport.on_load(:active_record) { extend Moderate::Macros }`,
- # so `participates_in_moderation`, `reportable`, and `moderates` become class methods on
+ # so `has_moderation_capabilities`, `reportable`, and `moderates` become class methods on
# ActiveRecord::Base — readable plain-English declarations that sit alongside the
# rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`):
#
- # class User < ApplicationRecord; participates_in_moderation; end
+ # class User < ApplicationRecord; has_moderation_capabilities; end
# class Listing < ApplicationRecord; reportable :title, :description; end
# class Message < ApplicationRecord; moderates :body, mode: :flag; end
#
@@ -32,13 +32,13 @@ module Moderate
# forward to that concern's declaration method. All behavior lives in the
# concerns, never here.
module Macros
- # `participates_in_moderation` — make this model an ACTOR (and, since a user is
+ # `has_moderation_capabilities` — make this model an ACTOR (and, since a user is
# usually itself reportable, a reportable too): report!/block!/unblock!/blocks?/
# blocked_with?, the block & report associations, and the be-banned target.
#
# Equivalent to `include Moderate::Actor`. Idempotent: re-declaring (or both
# macro + explicit include) won't double-include.
- def participates_in_moderation
+ def has_moderation_capabilities
include Moderate::Actor unless include?(Moderate::Actor)
end
diff --git a/lib/moderate/models/block.rb b/lib/moderate/models/block.rb
index c6e5e4f..c6b990d 100644
--- a/lib/moderate/models/block.rb
+++ b/lib/moderate/models/block.rb
@@ -91,11 +91,11 @@ def self.block!(blocker:, blocked:)
name: :user_blocked,
subject: block,
actor: blocker,
- payload: {
+ payload: on_block_payload.merge(
blocker_id: blocker.id,
blocked_id: blocked.id,
summary: "user #{blocker.id} blocked user #{blocked.id}"
- }.merge(on_block_payload)
+ )
)
end
end
@@ -190,6 +190,7 @@ def self.audit_payload_from_on_block(result)
rescue ArgumentError, TypeError
{}
end
+ private_class_method :audit_payload_from_on_block
# The DB has a CHECK constraint (`moderate_blocks_no_self_block`) too; this gives
# the friendly validation error before the row ever reaches the database.
diff --git a/lib/moderate/models/concerns/actor.rb b/lib/moderate/models/concerns/actor.rb
index e12bac1..286c028 100644
--- a/lib/moderate/models/concerns/actor.rb
+++ b/lib/moderate/models/concerns/actor.rb
@@ -3,7 +3,7 @@
module Moderate
# The "person who acts" in the Trust & Safety system: the model that reports
# other content, blocks other actors, gets reported, gets banned. Backs the
- # `participates_in_moderation` macro (and its documented equivalent,
+ # `has_moderation_capabilities` macro (and its documented equivalent,
# `include Moderate::Actor`).
#
# This is the one model the gem treats as the actor/identity, configured via
@@ -20,7 +20,7 @@ module Moderate
module Actor
extend ActiveSupport::Concern
- # A user is also reportable. Pulling Reportable in here means `participates_in_moderation`
+ # A user is also reportable. Pulling Reportable in here means `has_moderation_capabilities`
# alone gives you both halves (act AND be-acted-on) without a second macro.
include Moderate::Reportable
@@ -60,7 +60,7 @@ module Actor
# --- Reporting ------------------------------------------------------------
# File a report from this actor against a piece of content (or another actor —
- # a user with `participates_in_moderation` is itself reportable).
+ # a user with `has_moderation_capabilities` is itself reportable).
#
# current_user.report!(@message, category: :harassment, details: "...")
# current_user.report!(@other_user, category: :impersonation)
@@ -77,7 +77,9 @@ module Actor
# through, so this stays forward-compatible with the Report model's attributes.
def report!(reportable, category:, details: nil, **attributes)
attributes[:message] = details if details && !attributes.key?(:message)
- reported_field = attributes.delete(:reported_field) || attributes.delete(:field)
+ reported_field = attributes.delete(:reported_field)
+ field = attributes.delete(:field)
+ reported_field ||= field
# An in-app reporter attests to good faith IMPLICITLY by choosing to report —
# there's no separate checkbox in the in-app flow (that's the public DSA notice
diff --git a/lib/moderate/models/concerns/reportable.rb b/lib/moderate/models/concerns/reportable.rb
index bd2c4e5..f24a854 100644
--- a/lib/moderate/models/concerns/reportable.rb
+++ b/lib/moderate/models/concerns/reportable.rb
@@ -97,7 +97,7 @@ def reportable_field_allowed?(field)
# NO default: a model that can be reported MUST tell the gem who's behind it,
# because guessing wrong here means notifying or banning the wrong person.
# We raise a NotImplementedError naming the class so the omission is loud at
- # the first report, not silent. (A `User` model with `participates_in_moderation` is itself
+ # the first report, not silent. (A `User` model with `has_moderation_capabilities` is itself
# reportable and returns `self` — see Moderate::Actor.)
def reported_owner
raise NotImplementedError,
@@ -148,7 +148,7 @@ def remove_reported_field!(_field)
#
# The `moderate_report_link` helper renders nothing when this is false, and the
# report controller redirects. Hosts can override for richer rules. (A User with
- # `participates_in_moderation` overrides this in Moderate::Actor to compare ids directly,
+ # `has_moderation_capabilities` overrides this in Moderate::Actor to compare ids directly,
# since a user IS its own owner.)
def report_visible_to?(viewer, field:)
return false unless reportable_field_allowed?(field)
diff --git a/lib/moderate/models/flag.rb b/lib/moderate/models/flag.rb
index 0ba9fd4..a0db699 100644
--- a/lib/moderate/models/flag.rb
+++ b/lib/moderate/models/flag.rb
@@ -62,7 +62,7 @@ class Flag < ApplicationRecord
validates :field, presence: true
validates :status, inclusion: { in: STATUSES }
- validates :source, inclusion: { in: ->(_flag) { sources } }
+ validates :source, inclusion: { in: ->(_flag) { sources } }, on: :create
validates :mode, inclusion: { in: MODES }
validates :resolution_note, presence: true, if: :closed?
diff --git a/lib/moderate/models/report.rb b/lib/moderate/models/report.rb
index 6c4ebc8..348970e 100644
--- a/lib/moderate/models/report.rb
+++ b/lib/moderate/models/report.rb
@@ -363,7 +363,7 @@ def self.locate_signed_reportable_from_registry(token)
def self.locate_signed_reportable_by_contract(token)
record = GlobalID::Locator.locate_signed(token, for: SIGNED_GLOBAL_ID_PURPOSE)
- reportable_contract?(record) ? record : nil
+ reportable_contract?(record) && reportable_allowed?(record) ? record : nil
end
def self.reportable_contract?(record)
@@ -371,6 +371,17 @@ def self.reportable_contract?(record)
record.respond_to?(:reported_owner)
end
+ def self.reportable_allowed?(record)
+ return false if record.blank?
+
+ Moderate.reportable_classes.include?(record.class) ||
+ record.is_a?(Moderate::Reportable)
+ end
+ private_class_method :locate_signed_reportable_from_registry,
+ :locate_signed_reportable_by_contract,
+ :reportable_contract?,
+ :reportable_allowed?
+
# Coalesce the JSON columns to their empty shape so a NULL never reaches a
# NOT-NULL JSON column (the MySQL-no-JSON-default case — see the before_save
# comment). Hash-shaped columns default to {}, the list-shaped one to [].
diff --git a/test/configuration_test.rb b/test/configuration_test.rb
new file mode 100644
index 0000000..1aca4f4
--- /dev/null
+++ b/test/configuration_test.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ConfigurationTest < ActiveSupport::TestCase
+ class CountingAdapter
+ class << self
+ attr_accessor :instances
+ end
+ self.instances = 0
+
+ def initialize
+ self.class.instances += 1
+ end
+
+ def classify(_value)
+ Moderate::Result.allowed(source: "counting")
+ end
+ end
+
+ test "class adapter registrations are instantiated once and memoized" do
+ CountingAdapter.instances = 0
+ Moderate.config.register_adapter :counting, CountingAdapter
+
+ first = Moderate.config.adapter_for(:counting)
+ second = Moderate.config.adapter_for(:counting)
+
+ assert_same first, second
+ assert_equal 1, CountingAdapter.instances
+ end
+end
diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb
index 9d2f763..8b6362b 100644
--- a/test/dummy/app/models/user.rb
+++ b/test/dummy/app/models/user.rb
@@ -2,7 +2,7 @@
# The dummy host's ACTOR model — the `config.user_class`.
#
-# `participates_in_moderation` (the macro the engine adds to ActiveRecord::Base) makes a User
+# `has_moderation_capabilities` (the macro the engine adds to ActiveRecord::Base) makes a User
# able to report, block/unblock, be reported, and be banned; because Actor pulls in
# Reportable, a User is ALSO reportable (Apple 1.2 / Google Play UGC both require
# reporting AND blocking *users*, not just content). `reportable :name` narrows the
@@ -10,7 +10,7 @@
# whitelist (a report naming `:name` is allowed; one naming an undeclared field is
# rejected by Moderate::Report's reportable_field_must_be_allowed validation).
class User < ApplicationRecord
- participates_in_moderation
+ has_moderation_capabilities
reportable :name
# The initializer declares `config.filter "User", :name, mode: :flag`, so a User
diff --git a/test/dummy/config/initializers/moderate.rb b/test/dummy/config/initializers/moderate.rb
index 0fb050d..d31b483 100644
--- a/test/dummy/config/initializers/moderate.rb
+++ b/test/dummy/config/initializers/moderate.rb
@@ -25,7 +25,7 @@
# shared setup is the natural place to re-point the hooks at ModerateTestRecorder).
# This file documents the canonical wiring; the suite mirrors it post-reset.
Moderate.configure do |config|
- # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / participates_in_moderation).
+ # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / has_moderation_capabilities).
# Stored as a string, constantized lazily, so this works even though User isn't
# loaded yet at boot.
config.user_class = "User"
diff --git a/test/integration/appeal_form_test.rb b/test/integration/appeal_form_test.rb
index 898b395..887c245 100644
--- a/test/integration/appeal_form_test.rb
+++ b/test/integration/appeal_form_test.rb
@@ -94,6 +94,30 @@ class AppealFormTest < ActionDispatch::IntegrationTest
assert_match(/verify/i, flash[:alert].to_s)
end
+ test "a configured human verification skip bypasses the appeal browser guard" do
+ Moderate.config.appeal_guard = ->(_controller) { false }
+ Moderate.config.appeal_human_verification_skip_if = ->(controller) {
+ controller.request.user_agent.to_s.include?("Hotwire Native")
+ }
+ report = closed_report
+
+ assert_difference -> { Moderate::Appeal.count }, 1 do
+ post CREATE,
+ params: {
+ token: report.signed_appeal_gid,
+ appeal: {
+ appellant_name: "Appealing Person",
+ appellant_email: "appeal@example.com",
+ source: "notifier",
+ reason: "Please review this decision."
+ }
+ },
+ headers: { "User-Agent" => "Hotwire Native iOS" }
+ end
+
+ assert_redirected_to "/"
+ end
+
private
def closed_report(reported_user: nil)
diff --git a/test/integration/notice_form_test.rb b/test/integration/notice_form_test.rb
index ec0ea50..8299637 100644
--- a/test/integration/notice_form_test.rb
+++ b/test/integration/notice_form_test.rb
@@ -294,6 +294,24 @@ class NoticeFormTest < ActionDispatch::IntegrationTest
refute guard_ran, "the configurable guard must be bypassed when Turnstile is present"
end
+ test "a configured human verification skip bypasses Turnstile and hides the widget" do
+ NoticeFormTest.stub_turnstile!(pass: false)
+ Moderate.config.notice_human_verification_skip_if = ->(controller) {
+ controller.request.user_agent.to_s.include?("Hotwire Native")
+ }
+ headers = { "User-Agent" => "Hotwire Native iOS" }
+
+ get NEW, headers: headers
+ assert_response :success
+ assert_select "div.cf-turnstile-stub", false, "skipped requests should not render a browser captcha"
+
+ assert_difference -> { Moderate::Report.count }, 1 do
+ post CREATE, params: { notice: well_formed_notice_params }, headers: headers
+ end
+ assert_response :see_other
+ refute NoticeFormTest.turnstile_verified, "the verifier should not run for skipped requests"
+ end
+
# --- Turnstile stubbing helpers (class-level so setup/teardown can reset) ----
class << self
diff --git a/test/integration/reporting_test.rb b/test/integration/reporting_test.rb
index eef95d1..37b6e86 100644
--- a/test/integration/reporting_test.rb
+++ b/test/integration/reporting_test.rb
@@ -127,6 +127,19 @@ def current_user = test_viewer
assert_equal "body", report.reported_field
end
+ test "report! consumes both field aliases and prefers reported_field" do
+ report = @viewer.report!(
+ @comment,
+ category: :harassment,
+ reported_field: "body",
+ field: "title",
+ details: "abusive"
+ )
+
+ assert report.persisted?
+ assert_equal "body", report.reported_field
+ end
+
test "a user cannot report themselves" do
# Pass a valid message and a declared reportable field ("name" is User's only
# reportable field) so the ONLY thing that can fail is the self-report guard
diff --git a/test/macros_test.rb b/test/macros_test.rb
index bd81390..645369f 100644
--- a/test/macros_test.rb
+++ b/test/macros_test.rb
@@ -3,7 +3,7 @@
require "test_helper"
# Tests for the class-level DSL the engine adds to every ActiveRecord model:
-# `participates_in_moderation`, `reportable`, and `moderates` (Moderate::Macros, extended onto
+# `has_moderation_capabilities`, `reportable`, and `moderates` (Moderate::Macros, extended onto
# ActiveRecord::Base via `ActiveSupport.on_load(:active_record)`).
#
# Two angles:
@@ -14,15 +14,15 @@
# assertion sees the policy the macro just created rather than a boot-time one
# that reset! wiped.
class MacrosTest < ActiveSupport::TestCase
- # --- participates_in_moderation (Actor + Reportable) ----------------------
+ # --- has_moderation_capabilities (Actor + Reportable) ----------------------
- test "participates_in_moderation includes Moderate::Actor (and Reportable, since a user is reportable)" do
+ test "has_moderation_capabilities includes Moderate::Actor (and Reportable, since a user is reportable)" do
assert User.include?(Moderate::Actor)
# Actor pulls in Reportable — Apple 1.2 / Play UGC require reporting USERS too.
assert User.include?(Moderate::Reportable)
end
- test "participates_in_moderation gives an actor the report!/block! surface" do
+ test "has_moderation_capabilities gives an actor the report!/block! surface" do
actor = create_user
%i[report! block! unblock! blocks? blocked_by? blocked_with?].each do |method|
assert_respond_to actor, method, "expected actor to respond to ##{method}"
diff --git a/test/models/moderate/block_test.rb b/test/models/moderate/block_test.rb
index 405789c..e20dccb 100644
--- a/test/models/moderate/block_test.rb
+++ b/test/models/moderate/block_test.rb
@@ -6,8 +6,7 @@ module Moderate
# Tests for Moderate::Block — the bidirectional safety edge behind every "block"
# feature and behind Moderate.blocked_ids_for.
#
- # Ported from the reference suite (test/models/moderation/block_test.rb) and
- # de-host-ified: the host concepts (drivers, listings, join-request cancellation)
+ # Ported from the old host-shaped suite and de-host-ified: app-specific examples
# are gone, and assertions are realigned to the GEM's actual API — the SSOT method
# is `related_user_ids` (not the reference's `user_ids_related_to`), and the
# audit/notify payloads carry `:blocker_id`/`:blocked_id` (not whole records).
@@ -65,7 +64,15 @@ class BlockTest < ActiveSupport::TestCase
audits = []
Moderate.config.audit = ->(event) { audits << event }
Moderate.config.on_block = ->(blocker:, blocked:, at:) {
- { side_effect: "cancelled_invites", blocker_id_from_hook: blocker.id, blocked_id_from_hook: blocked.id, at_from_hook: at.iso8601 }
+ {
+ side_effect: "cancelled_invites",
+ blocker_id: "hook must not clobber this",
+ blocked_id: "hook must not clobber this",
+ summary: "hook must not clobber this",
+ blocker_id_from_hook: blocker.id,
+ blocked_id_from_hook: blocked.id,
+ at_from_hook: at.iso8601
+ }
}
Moderate::Block.block!(blocker: @blocker, blocked: @blocked)
@@ -74,6 +81,7 @@ class BlockTest < ActiveSupport::TestCase
assert_equal "cancelled_invites", audit.payload[:side_effect]
assert_equal @blocker.id, audit.payload[:blocker_id]
assert_equal @blocked.id, audit.payload[:blocked_id]
+ assert_equal "user #{@blocker.id} blocked user #{@blocked.id}", audit.payload[:summary]
assert_equal @blocker.id, audit.payload[:blocker_id_from_hook]
assert_equal @blocked.id, audit.payload[:blocked_id_from_hook]
assert audit.payload[:at_from_hook].present?
diff --git a/test/models/moderate/flag_test.rb b/test/models/moderate/flag_test.rb
index b2263d9..59d950f 100644
--- a/test/models/moderate/flag_test.rb
+++ b/test/models/moderate/flag_test.rb
@@ -87,6 +87,22 @@ class FlagTest < ActiveSupport::TestCase
assert flag.errors[:resolution_note].any?
end
+ test "a flag can be closed after its source adapter is no longer registered" do
+ Moderate.config.register_adapter :temporary, DummyImageAdapter.new
+ flag = Moderate::Flag.flag!(
+ flaggable: @user, field: "name", owner: @user, source: "temporary", mode: "flag",
+ excerpt: "x", categories: [], scores: {}, context: {}
+ )
+
+ Moderate.reset!
+ Moderate.configure { |config| config.user_class = "User" }
+
+ assert_nothing_raised do
+ flag.update!(status: "dismissed", resolution_note: "Handled after adapter cleanup.")
+ end
+ assert_equal "dismissed", flag.reload.status
+ end
+
test "source/mode/status are constrained to the allowed vocabularies (in the model)" do
# These vocabularies are enforced by ActiveModel inclusion validations, not DB
# check constraints, so each invalid value surfaces a friendly model error.
diff --git a/test/models/moderate/report_test.rb b/test/models/moderate/report_test.rb
index cea014a..a0d325e 100644
--- a/test/models/moderate/report_test.rb
+++ b/test/models/moderate/report_test.rb
@@ -6,9 +6,9 @@ module Moderate
# Tests for Moderate::Report — the core notice/report record (in-app community
# report AND public DSA legal notice, distinguished by `intake_kind`).
#
- # Ported from test/models/moderation/report_test.rb and fully de-host-ified: the
- # reference's marketplace listings are replaced by the dummy Comment (a reportable
- # piece of content) and the dummy User (itself reportable). Assertions track the
+ # Ported from the old host-shaped suite and fully de-host-ified: app-specific
+ # domain objects are replaced by the dummy Comment (a reportable piece of content)
+ # and the dummy User (itself reportable). Assertions track the
# GEM's columns and behavior — the immutable snapshot, the inferred reported_user,
# the signed-GlobalID locators, and the DSA automated-processing disclosure.
class ReportTest < ActiveSupport::TestCase
@@ -51,7 +51,7 @@ class ReportTest < ActiveSupport::TestCase
end
test "reportable classes are auto-discovered from the reportable macro" do
- # User (participates_in_moderation -> reportable) and Comment (reportable :body) both
+ # User (has_moderation_capabilities -> reportable) and Comment (reportable :body) both
# self-registered on inclusion — no manual registry.
assert_includes Moderate.reportable_classes, User
assert_includes Moderate.reportable_classes, Comment
@@ -196,6 +196,22 @@ class ReportTest < ActiveSupport::TestCase
registry.replace(original_registry) if registry && original_registry
end
+ test "signed reportable lookup rejects non-reportable records even when they duck-type the contract" do
+ duck_class = Class.new(ApplicationRecord) do
+ self.table_name = "comments"
+
+ def reportable_field_allowed?(_field) = true
+ def reported_owner = User.first
+ end
+ Object.const_set(:DuckReportable, duck_class)
+ record = DuckReportable.create!(user_id: @reporter.id, body: "contract-shaped but not reportable")
+ token = record.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)
+
+ assert_nil Moderate::Report.locate_signed_reportable(token)
+ ensure
+ Object.send(:remove_const, :DuckReportable) if Object.const_defined?(:DuckReportable, false)
+ end
+
test "automated_processing_used? is true when an auto-flag exists for the same target+field" do
author = create_user
comment = Comment.create!(user: author, body: "clean text")
From 5a7692dc2cac656d9a5290e6217576dd727f7477 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 17:55:04 +0100
Subject: [PATCH 07/15] Clarify compliance wording and block indexes
---
README.md | 8 +++++---
docs/compliance.md | 2 +-
docs/configuration.md | 2 +-
docs/dsa-notice-form.md | 6 +++---
docs/madmin.md | 4 ++--
.../moderate/templates/create_moderate_tables.rb.erb | 2 +-
lib/generators/moderate/templates/initializer.rb | 3 ++-
.../db/migrate/20260101000000_create_moderate_tables.rb | 2 +-
test/models/moderate/block_test.rb | 7 +++++++
9 files changed, 23 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 4b27a61..1402e24 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,9 @@
> [!TIP]
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=moderate)**, 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](https://railsfast.com/?ref=moderate)!
-`moderate` gives your Rails app a complete **Trust & Safety** layer — let users **report** abusive content, **block** each other, **filter** objectionable text and images before they're posted, and run a **moderation queue** your admins actually use. It ships **DSA-compliant** (EU Digital Services Act) and aligned with the **Apple App Store** and **Google Play** review guidelines for user-generated content, so you stop leaving store approvals and legal exposure to chance.
+`moderate` gives your Rails app a complete **Trust & Safety** layer — let users **report** abusive content, **block** each other, **filter** objectionable text and images before they're posted, and run a **moderation queue** your admins actually use. It ships **DSA-aligned primitives** (EU Digital Services Act) and Apple App Store / Google Play UGC mechanisms, so the core reporting, blocking, notice, appeal, transparency, and audit workflows are not scattered through your app.
+
+It 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` gives you the mechanisms when you need them, not a legal conclusion that every app must use every surface.
It reads like plain English. Make any model reportable:
@@ -54,7 +56,7 @@ It's the kind of plumbing nobody wants to build, everybody rebuilds, and almost
- **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.
-- **Comply**: DSA 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.
+- **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.
It works standalone, and gets better with the rest of the ecosystem.
@@ -223,7 +225,7 @@ Every backend implements the same tiny contract — `classify(value) → Moderat
| Adapter | Use it for | Notes |
| --- | --- | --- |
-| `:wordlist` (built-in, default) | text | Fast, multilingual, offline, zero-dependency. Unicode + leetspeak + spacing-evasion resistant. Ships `en`/`es` lists; add your own. The only adapter the gem ships. |
+| `: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`](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`](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". |
diff --git a/docs/compliance.md b/docs/compliance.md
index 1e71120..e0d0618 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -110,7 +110,7 @@ Apple is blunt: an app with UGC that lacks these gets **rejected**, and rejectio
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| **(a)** A method to **filter objectionable material** before it's posted. | `moderates :field` with `mode: :block` rejects the offending write before save; the default `:wordlist` adapter is multilingual and evasion-resistant, and you can register an image / remote adapter for stronger checks. | **[gem]** | `test/models/filtering_block_mode_test.rb` |
+| **(a)** A method to **filter objectionable material** before it's posted. | `moderates :field` with `mode: :block` rejects the offending write before save; the default `:wordlist` adapter is a fast offline baseline, and you can register an image / remote adapter for stronger checks. | **[gem]** | `test/models/filtering_block_mode_test.rb` |
| **(b)** A mechanism to **report** offensive content. | `current_user.report!(content, category:)` in-app; `reportable` content exposes `reports`, `reported?`, `flagged?`; the `moderate_report_link` helper drops the button into any view. | **[gem]** | `test/models/reportable_test.rb`, `test/helpers/report_link_test.rb` |
| **(b)** **Timely responses** to reports. | The report lands in `Moderate::Report.pending` with a snapshot; the reporter gets a `report_received` receipt immediately, and a `report_decision` when you act. (Acting promptly is on you — the gem surfaces the queue and the events.) | **[gem + you]** | `test/services/report_received_event_test.rb` |
| **(c)** The ability to **block abusive users**. | `current_user.block!(other)` — bidirectional, idempotent, audited; enforce it everywhere with the single `Moderate.blocked_ids_for(user)` query. | **[gem]** | `test/models/block_test.rb` |
diff --git a/docs/configuration.md b/docs/configuration.md
index e475dd3..7100d1a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -117,7 +117,7 @@ Exactly **one** adapter ships built in:
| Adapter | Use it for | Notes |
| --- | --- | --- |
-| `:wordlist` (default) | text | Fast, multilingual, **offline**, zero-dependency. Unicode + leetspeak + spacing-evasion resistant. Ships `en`/`es` lists; extend with `additional_words` / `excluded_words`. |
+| `:wordlist` (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; extend with `additional_words` / `excluded_words`. |
For anything nuanced — context-aware text, images, a hosted moderation API — you **bring and name your own adapter** with `register_adapter` (next section). Two ready-to-copy reference adapters live under [`examples/`](../examples/): `examples/openai_moderation_adapter.rb` (OpenAI `omni-moderation-latest`, text + image, via the `ruby_llm` gem) and `examples/aws_rekognition_adapter.rb` (image moderation via `aws-sdk-rekognition`). They are **not shipped, loaded, or a dependency** — copy one into your app, add its gem to *your* Gemfile, and register it. `moderate` intentionally does **not** ship a built-in "LLM" or image adapter: the contract is `classify(value) → Result`, and whether the backend behind your adapter is an LLM, a hosted endpoint, or a regex is your call, not the gem's.
diff --git a/docs/dsa-notice-form.md b/docs/dsa-notice-form.md
index ac68e2d..b361774 100644
--- a/docs/dsa-notice-form.md
+++ b/docs/dsa-notice-form.md
@@ -2,7 +2,7 @@
The EU **Digital Services Act, Article 16 ("Notice and action")** says every hosting service that serves EU users must offer a **public, electronic** way for *anyone* — not just logged-in users — to flag illegal content, and must **acknowledge receipt** of that notice. This is the form you see at the bottom of X, YouTube, Reddit: "Report illegal content (EU)". It is a hard requirement, it is separate from your in-app "Report" button, and it is exactly the kind of legally-loaded plumbing `moderate` exists to take off your plate.
-So `moderate` ships it as a **mountable Rails engine**: one line in your routes and you have a compliant, public notice form. The form, the controller, the model, the bot gate, the rate-limit, and the confirmation-of-receipt are all done for you. The default view is plain, accessible, and CSS-framework-agnostic — and it's **overridable the way Devise does it**: run one generator to eject the templates into your app and style them to match your brand.
+So `moderate` ships it as a **mountable Rails engine**: one line in your routes and you have a public notice form with the DSA Art. 16 mechanics built in. The form, the controller, the model, the bot gate, the rate-limit, and the confirmation-of-receipt are all done for you. The default view is plain, accessible, and CSS-framework-agnostic — and it's **overridable the way Devise does it**: run one generator to eject the templates into your app and style them to match your brand.
It is also **completely optional**. If you'd rather build the public notice page yourself (you already have a design system, you want it inside an existing controller, whatever), don't mount the engine — use `Moderate::Report` (with `intake_kind: "dsa"`) directly and skip everything below. The engine is a convenience, not a dependency.
@@ -47,7 +47,7 @@ rails generate moderate:views
Most of `moderate` is deliberately **UI-agnostic** — Trust & Safety lives in admin surfaces, and we don't presume to own your admin chrome. The DSA notice form is the **one exception**, for three reasons:
-1. **It must exist and it must be public.** Unlike the admin queue (which you'd build anyway), the Art. 16 form is a legal must-have that has nothing to do with your product UI. Shipping it means most apps get compliant with one line instead of researching the regulation.
+1. **It must exist and it must be public.** Unlike the admin queue (which you'd build anyway), the Art. 16 form is a legal must-have that has nothing to do with your product UI. Shipping it means most apps get the required intake mechanism with one line instead of researching the regulation.
2. **The fields are dictated by law, not by you.** The legal-reason taxonomy, the good-faith statement, the "exact URL" requirement, the EU member-state selector — these come straight from the DSA. There's no product decision to make, so there's nothing to design. We can ship a correct default.
3. **You still own the look.** A bundled-but-overridable view is the best of both: it works out of the box, and you can make it yours without forking the gem.
@@ -361,7 +361,7 @@ Moderate.configure do |config|
end
```
-Every one of these has a sensible default, so `mount Moderate::Engine => "/"` with an otherwise-empty config gives you a working, compliant form. See the [main configuration reference](../README.md#configuration-reference) for the rest of `moderate`.
+Every one of these has a sensible default, so `mount Moderate::Engine => "/"` with an otherwise-empty config gives you a working public notice form with the gem-owned Art. 16 mechanics. See the [main configuration reference](../README.md#configuration-reference) for the rest of `moderate`.
## See also
diff --git a/docs/madmin.md b/docs/madmin.md
index 069e9c9..153af81 100644
--- a/docs/madmin.md
+++ b/docs/madmin.md
@@ -73,7 +73,7 @@ That's the whole pattern. The rest of this doc fills in the resource definitions
The same `pending` scope is what a **human admin** reads in `madmin` *and* what an **automated ML consumer** reads in a background job — one queue, two readers. (More on that in [Automated review](#automated-review-the-same-pending-queue).)
> [!IMPORTANT]
-> Decisions are **only** ever made by calling the gem's methods (`resolve!`, `dismiss!`, `uphold!`, `reject!`). Don't let madmin's stock edit form mutate `status` directly. Every decision is atomic, requires a moderator + a note, runs your enforcement (content removal via the reportable's own `remove_reported_field!`, bans via your `ban_handler`), fires the `notify` / `audit` hooks, and stamps the appeal window. A raw `status = "resolved"` update skips all of that and leaves you non-compliant. Keep the models **read-only** in madmin (`form: false`) and route every change through a custom member action — exactly what this guide does.
+> Decisions are **only** ever made by calling the gem's methods (`resolve!`, `dismiss!`, `uphold!`, `reject!`). Don't let madmin's stock edit form mutate `status` directly. Every decision is atomic, requires a moderator + a note, runs your enforcement (content removal via the reportable's own `remove_reported_field!`, bans via your `ban_handler`), fires the `notify` / `audit` hooks, and stamps the appeal window. A raw `status = "resolved"` update skips the audited decision workflow. Keep the models **read-only** in madmin (`form: false`) and route every change through a custom member action — exactly what this guide does.
---
@@ -479,7 +479,7 @@ To be explicit about the boundary this guide sits on:
| The optional `Moderate::Moderation` controller concern | Auth (`current_user`, admin gate) |
| Helpers + the evidence snapshot on each record | Your branding, layout, extra columns |
-You wire it once and you have a real, compliant moderation queue, in your own admin, in an afternoon.
+You wire it once and you have a real, audited moderation queue, in your own admin, in an afternoon.
## See also
diff --git a/lib/generators/moderate/templates/create_moderate_tables.rb.erb b/lib/generators/moderate/templates/create_moderate_tables.rb.erb
index 81e563c..fc30a9d 100644
--- a/lib/generators/moderate/templates/create_moderate_tables.rb.erb
+++ b/lib/generators/moderate/templates/create_moderate_tables.rb.erb
@@ -101,7 +101,7 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
# ---------------------------------------------------------------------------
create_table :moderate_blocks, id: primary_key_type do |t|
t.references :blocker, type: foreign_key_type, null: false
- t.references :blocked, type: foreign_key_type, null: false
+ t.references :blocked, type: foreign_key_type, null: false, index: { name: "index_moderate_blocks_on_blocked_id" }
t.timestamps
end
diff --git a/lib/generators/moderate/templates/initializer.rb b/lib/generators/moderate/templates/initializer.rb
index 7464347..dfb3661 100644
--- a/lib/generators/moderate/templates/initializer.rb
+++ b/lib/generators/moderate/templates/initializer.rb
@@ -34,7 +34,8 @@
# bring-your-own (`register_adapter`, below):
#
# :wordlist - fast, multilingual, offline wordlist (ships en/es). The ONLY
- # built-in. Unicode + leetspeak + spacing-evasion resistant.
+ # built-in. Fast offline baseline; register a remote/contextual
+ # adapter when you need stronger checks.
#
# Default: :wordlist
# config.filter_adapter = :wordlist
diff --git a/test/dummy/db/migrate/20260101000000_create_moderate_tables.rb b/test/dummy/db/migrate/20260101000000_create_moderate_tables.rb
index 2540c30..e22c3af 100644
--- a/test/dummy/db/migrate/20260101000000_create_moderate_tables.rb
+++ b/test/dummy/db/migrate/20260101000000_create_moderate_tables.rb
@@ -119,7 +119,7 @@ def change
# ---------------------------------------------------------------------------
create_table :moderate_blocks, id: primary_key_type do |t|
t.references :blocker, type: foreign_key_type, null: false
- t.references :blocked, type: foreign_key_type, null: false
+ t.references :blocked, type: foreign_key_type, null: false, index: { name: "index_moderate_blocks_on_blocked_id" }
t.timestamps
end
diff --git a/test/models/moderate/block_test.rb b/test/models/moderate/block_test.rb
index e20dccb..c2ac15d 100644
--- a/test/models/moderate/block_test.rb
+++ b/test/models/moderate/block_test.rb
@@ -30,6 +30,13 @@ class BlockTest < ActiveSupport::TestCase
end
end
+ test "blocked_id has its own index for the incoming blocked_ids_for lookup" do
+ indexes = Moderate::Block.connection.indexes(Moderate::Block.table_name)
+
+ assert indexes.any? { |index| index.columns == ["blocked_id"] },
+ "expected an index on moderate_blocks.blocked_id"
+ end
+
test "block! audits and notifies :user_blocked ONLY on a real (new) block" do
audits = []
notifications = []
From 84bfdefb5910dfa793ae98fdaf26d00e83a1f257 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 17:56:35 +0100
Subject: [PATCH 08/15] Soften wordlist matcher comments
---
lib/moderate/filters/wordlist.rb | 4 ++--
test/filters/wordlist_test.rb | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/lib/moderate/filters/wordlist.rb b/lib/moderate/filters/wordlist.rb
index b210da4..9a8f33c 100644
--- a/lib/moderate/filters/wordlist.rb
+++ b/lib/moderate/filters/wordlist.rb
@@ -37,8 +37,8 @@ module Filters
# The normalized text is then matched in TWO forms: the single-spaced form
# (so word-boundary patterns like "\bkill yourself\b" work), AND a space-removed
# "compact" form (so spacing evasion "f u c k" -> "fuck" is caught). A pattern
- # hits if it matches EITHER form. This is the proven, evasion-resistant
- # matching strategy the adapter relies on.
+ # hits if it matches EITHER form. This is a fast offline baseline matching
+ # strategy the adapter relies on.
#
# ── Output ───────────────────────────────────────────────────────────────────
# On a hit, returns a flagged Moderate::Result whose labels are the canonical
diff --git a/test/filters/wordlist_test.rb b/test/filters/wordlist_test.rb
index 10c25f5..af3eddb 100644
--- a/test/filters/wordlist_test.rb
+++ b/test/filters/wordlist_test.rb
@@ -5,7 +5,7 @@
# Tests for the built-in offline TEXT adapter, Moderate::Filters::Wordlist
# (registered as :wordlist, the default text adapter).
#
-# This is the evasion-resistant matcher: a fast, offline, multilingual wordlist
+# This is the fast offline baseline matcher: a multilingual wordlist
# that emits the gem's canonical taxonomy labels. We test it directly (the class)
# so a failure points straight at the matcher, and also through Moderate.classify
# (the facade) to prove the spine stamps the adapter name onto the Result's source.
From f97f5c28228369c4855592ba31d006b831341d95 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 18:56:42 +0100
Subject: [PATCH 09/15] Expose reportable flag predicates
---
README.md | 5 ++
docs/configuration.md | 11 +++
lib/moderate/models/concerns/reportable.rb | 52 +++++++++++++
test/models/moderate/reportable_test.rb | 88 ++++++++++++++++++++++
4 files changed, 156 insertions(+)
create mode 100644 test/models/moderate/reportable_test.rb
diff --git a/README.md b/README.md
index 1402e24..4257070 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,7 @@ You get:
listing.reports # reports filed against this record
listing.reported? # any open report?
listing.flagged? # any pending system (auto-filter) flag?
+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):
@@ -186,6 +187,10 @@ Drop a report link into any view with the helper (it renders nothing if the view
<%= moderate_report_link(@listing, field: :description) %>
```
+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 `/legal/report/notices/new`, `/legal/report/notices`, `/legal/report/appeals/new`, and `/legal/report/appeals`. `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 `reportable` line — the intake, queue, snapshot, and admin code never change.
## 🧪 Content filtering: `:off` / `:block` / `:flag`
diff --git a/docs/configuration.md b/docs/configuration.md
index 7100d1a..f56fd1f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -101,6 +101,17 @@ end
> [!IMPORTANT]
> `:flag` never lives in a validator. Validators must be side-effect-free, and a flag created inside a rolled-back transaction would silently vanish — so `moderate` creates the flag **after commit**, correctly, for you. This is the whole reason `:flag` is a `moderates` mode and not something you can hand-roll with `validates`.
+Reportable records expose the review state directly:
+
+```ruby
+message.flagged? # any pending flag?
+message.flagged?(:body) # pending flag for one field?
+```
+
+Use those predicates to render host-specific "under review" affordances if that is right for your product. The gem intentionally does not ship a visible banner/component because moderation copy, styling, and disclosure rules belong to the host app.
+
+Hotwire Native / Turbo Native apps also need host path-configuration rules for the report surfaces they mount. Cover both the form route (`/reports/new`, or your equivalent) and the form action (`/reports`) so validation errors stay in the intended native context, plus the engine's public legal routes and their form actions if you mount them (`/legal/report/notices/new`, `/legal/report/notices`, `/legal/report/appeals/new`, `/legal/report/appeals`, transparency, etc.). Android rules must include the destination `uri` your app binary has registered.
+
### `filter_adapter`
```ruby
diff --git a/lib/moderate/models/concerns/reportable.rb b/lib/moderate/models/concerns/reportable.rb
index f24a854..7518ae1 100644
--- a/lib/moderate/models/concerns/reportable.rb
+++ b/lib/moderate/models/concerns/reportable.rb
@@ -41,6 +41,15 @@ module Reportable
extend ActiveSupport::Concern
included do
+ # Reports filed against THIS record. Kept on the public `reports` reader
+ # because the README promises `listing.reports`, and because a reportable
+ # model should read naturally in host code. Reports are legal/evidentiary,
+ # so hard-deleting the content detaches rather than destroys them.
+ has_many :reports,
+ as: :reportable,
+ class_name: "Moderate::Report",
+ dependent: :nullify
+
# The whitelist of reportable field names, stored as frozen Strings. A
# `class_attribute` (not a plain constant) so it inherits down an STI tree
# AND can be overridden per subclass without mutating the parent's list.
@@ -157,6 +166,42 @@ def report_visible_to?(viewer, field:)
true
end
+ # Open reports filed against this record, optionally narrowed to one field.
+ # Hosts can use this when they need the actual relation (queue previews,
+ # counters, "already reported" affordances) instead of just a boolean.
+ def open_reports(field = nil)
+ moderation_scope_by_field(reports.open, :reported_field, field)
+ end
+
+ # Has this record received any open reports? This is the public predicate
+ # documented beside `reports` in the README.
+ def reported?(field = nil)
+ open_reports(field).exists?
+ end
+
+ # All auto-filter/manual flags against this record, optionally narrowed to a
+ # field. We keep this as a method instead of a `has_many :flags` association:
+ # `flags` is a common host-model word, while `flagged?` is the public DX.
+ def moderation_flags(field = nil)
+ moderation_scope_by_field(
+ Moderate::Flag.where(flaggable: self),
+ :field,
+ field
+ )
+ end
+
+ # Pending flags are the "allowed through, awaiting review" state a host may
+ # want to surface near user-generated content.
+ def pending_moderation_flags(field = nil)
+ moderation_flags(field).pending
+ end
+
+ # Has this record been flagged and not yet resolved/dismissed? Optionally
+ # pass a field (`listing.flagged?(:description)`) for field-level UI.
+ def flagged?(field = nil)
+ pending_moderation_flags(field).exists?
+ end
+
# --- Route descriptor hooks -----------------------------------------------
#
# The gem is UI-agnostic and doesn't know the host's routes, so a reportable
@@ -213,5 +258,12 @@ def moderation_owner_is?(viewer)
rescue NotImplementedError
false
end
+
+ def moderation_scope_by_field(scope, column, field)
+ field_s = field.to_s.squish
+ return scope if field_s.blank?
+
+ scope.where(column => field_s)
+ end
end
end
diff --git a/test/models/moderate/reportable_test.rb b/test/models/moderate/reportable_test.rb
new file mode 100644
index 0000000..a3c6d0a
--- /dev/null
+++ b/test/models/moderate/reportable_test.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Moderate
+ class ReportableTest < ActiveSupport::TestCase
+ setup do
+ @reporter = create_user
+ @author = create_user
+ @comment = Comment.create!(user: @author, body: "a normal comment")
+ end
+
+ test "reports exposes reports filed against the record" do
+ report = Moderate::Report.create!(
+ reporter: @reporter,
+ reportable: @comment,
+ reported_field: "body",
+ category: "harassment",
+ message: "This should be reviewed.",
+ good_faith_confirmed: true
+ )
+
+ assert_includes @comment.reports, report
+ assert_predicate @comment, :reported?
+ assert @comment.reported?(:body)
+ refute @comment.reported?(:image)
+ end
+
+ test "reported? only counts open reports" do
+ report = Moderate::Report.create!(
+ reporter: @reporter,
+ reportable: @comment,
+ reported_field: "body",
+ category: "harassment",
+ message: "This should be reviewed.",
+ good_faith_confirmed: true
+ )
+ report.update!(status: "dismissed", resolution_note: "No violation.")
+
+ refute @comment.reported?
+ refute @comment.reported?(:body)
+ end
+
+ test "flagged? only counts pending flags on the requested field" do
+ body_flag = flag_comment!("body")
+ flag_comment!("image")
+
+ assert_predicate @comment, :flagged?
+ assert @comment.flagged?(:body)
+ assert @comment.flagged?("image")
+ refute @comment.flagged?(:title)
+
+ body_flag.update!(status: "dismissed", resolution_note: "False positive.")
+
+ refute @comment.flagged?(:body)
+ assert @comment.flagged?(:image)
+ end
+
+ test "pending_moderation_flags returns the field-scoped relation" do
+ body_flag = flag_comment!("body")
+ flag_comment!("image")
+
+ assert_equal [ body_flag ], @comment.pending_moderation_flags(:body).to_a
+ end
+
+ private
+
+ def create_user(**attributes)
+ @user_seq ||= 0
+ @user_seq += 1
+ User.create!(name: "User #{@user_seq}", email: "user#{@user_seq}@example.com", **attributes)
+ end
+
+ def flag_comment!(field)
+ Moderate::Flag.flag!(
+ flaggable: @comment,
+ field: field,
+ owner: @author,
+ source: "manual",
+ mode: "flag",
+ excerpt: "#{field} excerpt",
+ categories: [],
+ scores: {},
+ context: {}
+ )
+ end
+ end
+end
From b1f7fcd169bec919429f5a919f90f3ae6a96c59b Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 22:33:59 +0100
Subject: [PATCH 10/15] Refine trust safety docs and reportable hooks
---
CHANGELOG.md | 72 +++++++++++++++++++++-
Gemfile.lock | 2 +-
README.md | 4 +-
docs/configuration.md | 2 +-
lib/moderate/models/concerns/reportable.rb | 13 ++++
moderate.gemspec | 9 +--
test/models/moderate/reportable_test.rb | 68 ++++++++++++++++++++
7 files changed, 161 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d3196d..e342a5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,73 @@
+# Changelog
+
+All notable changes to this project are documented here.
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0] - unreleased
+
+A complete, ground-up rewrite. `moderate` graduates from a single-purpose profanity
+validator (0.1.0) into a full **Trust & Safety** engine for Rails apps with user-generated
+content: report, block, filter, a moderation queue, appeals, and EU DSA / App Store / Google
+Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
+
+> **Breaking:** 1.0 keeps the gem name but is an entirely new API. The 0.x profanity
+> validator (`validates :field, moderate: true`) still loads for backward compatibility
+> (see _Upgrading from 0.x_), but everything else is new. Pin `~> 0.1` if you relied on the
+> old behavior and are not ready to adopt the new surface.
+
+### Added
+
+- **Reporting.** `Moderate::Report` plus the `reportable :fields` macro and
+ `Actor#report!(reportable, category:, details:)`. Reports and DSA notices share one model
+ and one queue (`intake_kind: "community" | "dsa"`).
+- **Blocking.** `Moderate::Block`, the `has_moderation_capabilities` actor macro, and
+ `block!` / `unblock!` / `blocks?` / `blocked_by?` / `blocked_with?`. `Moderate.blocked_ids_for(user)`
+ is the bidirectional single source of truth you compose into feed/search/inbox queries.
+ Optional `config.on_block` teardown hook runs inside the block transaction.
+- **Content filtering.** The `moderates :field, mode: :off|:block|:flag, with: :adapter` macro
+ (and the equivalent `config.filter`), the offline multilingual `:wordlist` adapter (the only
+ built-in), the `classify(value) => Moderate::Result` adapter contract with
+ `config.register_adapter`, asynchronous classification via `Moderate::ClassifyJob`, and
+ ready-to-copy reference adapters for OpenAI omni-moderation and AWS Rekognition under
+ `examples/` (bring-your-own, never a dependency).
+- **Moderation queue & decisions.** `Moderate::Flag` and the service objects
+ `Moderate::Services::{IntakeReport, ResolveReport, ResolveFlag, IntakeAppeal, ResolveAppeal,
+ IntakeNotice}`. Decisions are taken under a row lock, re-check open state, apply enforcement
+ (remove content / ban) inside the transaction, and fire notifications outside it; the appeal
+ window and statement-of-reasons fields are stamped automatically.
+- **DSA-aligned primitives.** A mountable public **notice-and-action** form (Art. 16) you mount
+ at any path, **statement of reasons** (Art. 17), internal **appeals** (Art. 20), and
+ **transparency** counters (Art. 24). The notice form prefills from query params + the signed-in
+ user, locks auto-filled identity fields, and auto-detects `rails_cloudflare_turnstile`.
+- **Hooks (all no-op by default).** `config.audit`, `config.notify` (returns a delivery boolean
+ used to gate `decision_notified_at`), `config.on_block`, `config.ban_handler`, the
+ host-overridable `config.report_categories`, and `config.notice_human_verification_skip_if` /
+ `config.appeal_human_verification_skip_if` for native-app bot-gate carve-outs.
+- **Optional integrations**, all auto-detected at runtime via `defined?`/`respond_to?` and never
+ hard dependencies: madmin, goodmail, telegrama, noticed, rails_cloudflare_turnstile.
+- **Install tooling.** `rails generate moderate:install` writes a documented initializer and an
+ adaptive migration (uuid/bigint primary keys, jsonb/json/MySQL JSON columns); `moderate:views`
+ ejects the notice form for customization.
+
+### Changed
+
+- Taxonomies (report categories, DSA legal reasons, country codes) are now frozen model
+ constants with inclusion validations instead of DB `CHECK` constraints — adding or
+ customizing a category needs no migration.
+- External classifiers (OpenAI, image moderation) are reference adapters in `examples/`, not
+ shipped or loaded code — the gem core forces no service dependency on apps that never use it.
+- All "DSA-compliant" / "App Store compliant" language reframed to **DSA-aligned primitives**:
+ the gem ships the mechanisms the law and the stores require; your policies, response times,
+ and operations are still yours.
+
+### Upgrading from 0.x
+
+- The 0.x profanity validator still loads: `validates :field, moderate: true` continues to work
+ via compatibility shims, so existing apps keep validating. To adopt 1.0, add
+ `has_moderation_capabilities` to your user model and `reportable` / `moderates` to your
+ content models, run `rails generate moderate:install`, and migrate.
+
## [0.1.0] - 2024-11-03
-- Initial release
+- Initial release (profanity validator).
diff --git a/Gemfile.lock b/Gemfile.lock
index 60005cb..6b7b374 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -4,7 +4,7 @@ PATH
moderate (1.0.0.beta1)
activerecord (>= 7.1.0, < 9.0)
activesupport (>= 7.1.0, < 9.0)
- globalid (>= 1.0)
+ globalid (~> 1.0)
railties (>= 7.1.0, < 9.0)
GEM
diff --git a/README.md b/README.md
index 4257070..4e55017 100644
--- a/README.md
+++ b/README.md
@@ -177,7 +177,7 @@ You get:
```ruby
listing.reports # reports filed against this record
listing.reported? # any open report?
-listing.flagged? # any pending system (auto-filter) flag?
+listing.flagged? # any pending flag (auto-filter OR manual)?
listing.flagged?(:description) # field-level pending flag?
```
@@ -189,7 +189,7 @@ Drop a report link into any view with the helper (it renders nothing if the view
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 `/legal/report/notices/new`, `/legal/report/notices`, `/legal/report/appeals/new`, and `/legal/report/appeals`. `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.
+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 `/notices/new`, `/notices`, `/appeals/new`, `/appeals`, and `/transparency` — where `` 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 `reportable` line — the intake, queue, snapshot, and admin code never change.
diff --git a/docs/configuration.md b/docs/configuration.md
index f56fd1f..27607fc 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -110,7 +110,7 @@ message.flagged?(:body) # pending flag for one field?
Use those predicates to render host-specific "under review" affordances if that is right for your product. The gem intentionally does not ship a visible banner/component because moderation copy, styling, and disclosure rules belong to the host app.
-Hotwire Native / Turbo Native apps also need host path-configuration rules for the report surfaces they mount. Cover both the form route (`/reports/new`, or your equivalent) and the form action (`/reports`) so validation errors stay in the intended native context, plus the engine's public legal routes and their form actions if you mount them (`/legal/report/notices/new`, `/legal/report/notices`, `/legal/report/appeals/new`, `/legal/report/appeals`, transparency, etc.). Android rules must include the destination `uri` your app binary has registered.
+Hotwire Native / Turbo Native apps also need host path-configuration rules for the report surfaces they mount. Cover both the form route (`/reports/new`, or your equivalent) and the form action (`/reports`) so validation errors stay in the intended native context, plus the engine's public legal routes and their form actions if you mount them (`/notices/new`, `/notices`, `/appeals/new`, `/appeals`, `/transparency`, where `` is your host-chosen `Moderate::Engine` mount point). Android rules must include the destination `uri` your app binary has registered.
### `filter_adapter`
diff --git a/lib/moderate/models/concerns/reportable.rb b/lib/moderate/models/concerns/reportable.rb
index 7518ae1..9f4900d 100644
--- a/lib/moderate/models/concerns/reportable.rb
+++ b/lib/moderate/models/concerns/reportable.rb
@@ -146,6 +146,19 @@ def remove_reported_field!(_field)
false
end
+ # Companion query to `remove_reported_field!`: CAN this specific `field` be
+ # removed on this record? An admin UI uses it to decide whether to OFFER a
+ # "remove content" action at all. Without it, a host that only removes SOME
+ # fields (e.g. an avatar but not a display name) would render a remove button
+ # that always fails when the moderator clicks it on a non-removable field.
+ #
+ # Defaults to false (mirrors the no-op `remove_reported_field!`). Override it
+ # alongside `remove_reported_field!` and have the latter reuse it, so the
+ # "can I?" answer and the "do it" action never drift apart.
+ def removable_reported_field?(_field)
+ false
+ end
+
# Visibility/authorization gate for the report affordance: should `viewer` be
# offered a "report this" control for `field`? The default enforces two rules:
#
diff --git a/moderate.gemspec b/moderate.gemspec
index 994b7d0..679c072 100644
--- a/moderate.gemspec
+++ b/moderate.gemspec
@@ -8,14 +8,15 @@ Gem::Specification.new do |spec|
spec.authors = ["rameerez"]
spec.email = ["rubygems@rameerez.com"]
- spec.summary = "Trust & Safety for your Rails app: report, block, filter, and an EU DSA / App Store / Play compliant moderation queue"
- spec.description = "moderate is a complete, opinionated Trust & Safety layer for Rails apps with user-generated content. Let users report abusive content and other users, block each other (bidirectional, enforced everywhere), and filter objectionable text and images before they're posted (off/block/flag, with pluggable wordlist/image/LLM backends). Run a real moderation queue with audited resolve/dismiss/remove-content/ban actions, internal appeals, and statement-of-reasons notifications. Ships aligned with the EU Digital Services Act (notice-and-action, statement of reasons, appeals, transparency) and the Apple App Store and Google Play user-generated-content review guidelines. UI-agnostic primitives (models, services, helpers, controller concerns) that plug into madmin, goodmail, telegrama, and noticed."
+ spec.summary = "Trust & Safety and content moderation for Rails: report abusive content, block users, filter objectionable text and images, and run an audited moderation queue — with DSA-aligned and App Store / Google Play UGC primitives."
+ spec.description = "moderate is a complete Trust & Safety and content moderation engine for Ruby on Rails apps with user-generated content (UGC) — social apps, marketplaces, dating, communities, forums, comments, reviews, and chat. It bundles the four things every UGC app needs behind one data model and one set of hooks: abuse reporting (report posts, comments, profiles, listings, messages, and other users), bidirectional user blocking behind a single enforced source of truth, pre-publication content filtering for profanity, slurs, hate, spam, harassment, and objectionable or NSFW text and images in off/block/flag modes, and an audited moderation queue with locked resolve/dismiss/remove-content/ban decisions, internal appeals, and statement-of-reasons notifications. Filtering uses a tiny classify(value) => Result adapter contract: a fast, offline, multilingual wordlist/profanity/bad-word blocklist ships as the zero-dependency default, and you bring your own classifier (OpenAI omni-moderation, AWS Rekognition image/NSFW detection, Google Perspective, or self-hosted) as an optional reference adapter, never a forced dependency. moderate also ships primitives aligned with the EU Digital Services Act (DSA notice-and-action, statement of reasons, appeals, transparency) and with the Apple App Store (Guideline 1.2) and Google Play user-generated-content review rules that get apps rejected without report/block/filter — the mechanisms, not a compliance certificate. It is a mountable, UI-agnostic Rails engine with no hard dependencies beyond ActiveRecord, ActiveSupport, Railties, and GlobalID, and optionally auto-integrates with madmin, goodmail, telegrama, noticed, and rails_cloudflare_turnstile — a modern, full-stack alternative to single-purpose Rails profanity filters like obscenity and profanity-filter."
spec.homepage = "https://github.com/rameerez/moderate"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
- spec.metadata["homepage_uri"] = spec.homepage
+ # NOTE: `homepage_uri` is derived from `spec.homepage` automatically — setting it
+ # here too only triggers a "same URI for multiple keys" warning, so we don't.
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
@@ -41,6 +42,6 @@ Gem::Specification.new do |spec|
# hard dependencies, so `moderate` runs standalone in any Rails app.
spec.add_dependency "activerecord", ">= 7.1.0", "< 9.0"
spec.add_dependency "activesupport", ">= 7.1.0", "< 9.0"
- spec.add_dependency "globalid", ">= 1.0"
+ spec.add_dependency "globalid", "~> 1.0"
spec.add_dependency "railties", ">= 7.1.0", "< 9.0"
end
diff --git a/test/models/moderate/reportable_test.rb b/test/models/moderate/reportable_test.rb
index a3c6d0a..79af281 100644
--- a/test/models/moderate/reportable_test.rb
+++ b/test/models/moderate/reportable_test.rb
@@ -3,6 +3,21 @@
require "test_helper"
module Moderate
+ class RemovableComment < ::Comment
+ self.table_name = "comments"
+
+ def removable_reported_field?(field)
+ field.to_s == "body" && body.present?
+ end
+
+ def remove_reported_field!(field)
+ return false unless removable_reported_field?(field)
+
+ update!(body: nil)
+ true
+ end
+ end
+
class ReportableTest < ActiveSupport::TestCase
setup do
@reporter = create_user
@@ -11,6 +26,8 @@ class ReportableTest < ActiveSupport::TestCase
end
test "reports exposes reports filed against the record" do
+ refute_predicate @comment, :reported?
+
report = Moderate::Report.create!(
reporter: @reporter,
reportable: @comment,
@@ -26,6 +43,24 @@ class ReportableTest < ActiveSupport::TestCase
refute @comment.reported?(:image)
end
+ test "reported? works on actor reportables and starts false with no rows" do
+ refute_predicate @author, :reported?
+
+ report = Moderate::Report.create!(
+ reporter: @reporter,
+ reportable: @author,
+ reported_field: "name",
+ category: "impersonation",
+ message: "This profile should be reviewed.",
+ good_faith_confirmed: true
+ )
+
+ assert_includes @author.reports, report
+ assert_predicate @author, :reported?
+ assert @author.reported?(:name)
+ refute @author.reported?(:avatar)
+ end
+
test "reported? only counts open reports" do
report = Moderate::Report.create!(
reporter: @reporter,
@@ -42,6 +77,8 @@ class ReportableTest < ActiveSupport::TestCase
end
test "flagged? only counts pending flags on the requested field" do
+ refute_predicate @comment, :flagged?
+
body_flag = flag_comment!("body")
flag_comment!("image")
@@ -56,6 +93,37 @@ class ReportableTest < ActiveSupport::TestCase
assert @comment.flagged?(:image)
end
+ test "flagged? works on actor reportables and starts false with no rows" do
+ refute_predicate @author, :flagged?
+
+ Moderate::Flag.flag!(
+ flaggable: @author,
+ field: "name",
+ owner: @author,
+ source: "manual",
+ mode: "flag",
+ excerpt: "name excerpt",
+ categories: [],
+ scores: {},
+ context: {}
+ )
+
+ assert_predicate @author, :flagged?
+ assert @author.flagged?(:name)
+ refute @author.flagged?(:avatar)
+ end
+
+ test "removable_reported_field? defaults false and can be overridden by a reportable" do
+ refute @comment.removable_reported_field?(:body)
+ refute @comment.remove_reported_field!(:body)
+
+ removable = RemovableComment.create!(user: @author, body: "remove me")
+
+ assert removable.removable_reported_field?(:body)
+ assert removable.remove_reported_field!(:body)
+ assert_nil removable.reload.body
+ end
+
test "pending_moderation_flags returns the field-scoped relation" do
body_flag = flag_comment!("body")
flag_comment!("image")
From 91edcad10ab8fd96903a615fb455c1d69ed50dee Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Tue, 2 Jun 2026 23:22:57 +0100
Subject: [PATCH 11/15] Fix SQLite CI appraisal matrix
Co-authored-by: Claude
---
.github/workflows/test.yml | 9 ++++++-
app/views/moderate/notices/new.html.erb | 2 +-
gemfiles/rails_7.1.gemfile | 36 +++++++++++++++++++++++++
gemfiles/rails_7.2.gemfile | 36 +++++++++++++++++++++++++
gemfiles/rails_8.1.gemfile | 36 +++++++++++++++++++++++++
5 files changed, 117 insertions(+), 2 deletions(-)
create mode 100644 gemfiles/rails_7.1.gemfile
create mode 100644 gemfiles/rails_7.2.gemfile
create mode 100644 gemfiles/rails_8.1.gemfile
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 84d6cfa..278e46d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -44,7 +44,14 @@ jobs:
- name: Prepare database and run tests
# Exercise the real migration path in SQLite too so dummy/test schema
# drift is caught in the default matrix, not only adapter-specific jobs.
- run: bundle exec rake db:migrate:reset test
+ #
+ # Use `db:create db:migrate` rather than `db:migrate:reset`: the reset macro
+ # runs db:drop + db:create + db:migrate IN ONE PROCESS, and on SQLite the
+ # db:migrate step then writes through a stale connection to the just-dropped
+ # file, so nothing persists and the suite boots into "pending migrations"
+ # (PG/MySQL survive it because the DB server reconnects). CI runners start
+ # fresh, so no drop is needed.
+ run: bundle exec rake db:create db:migrate test
- name: Upload test results
if: failure()
diff --git a/app/views/moderate/notices/new.html.erb b/app/views/moderate/notices/new.html.erb
index eb080ff..60daa47 100644
--- a/app/views/moderate/notices/new.html.erb
+++ b/app/views/moderate/notices/new.html.erb
@@ -141,7 +141,7 @@
line; the model normalizes into subject_urls and keeps subject_url as first. %>
<%= form.label :subject_urls, t("moderate.notices.fields.content_url", default: "Exact URL of the content") %>
- <%= text_area_tag "notice[subject_urls]", @report.subject_url_list.join("\n"), rows: 3, required: true, placeholder: "https://" %>
+
<%= t("moderate.notices.hints.content_url", default: "The exact link to the specific content you are reporting.") %>
diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile
new file mode 100644
index 0000000..1b664e7
--- /dev/null
+++ b/gemfiles/rails_7.1.gemfile
@@ -0,0 +1,36 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 7.1.0"
+
+group :development do
+ gem "appraisal"
+ gem "web-console"
+ gem "standard"
+ gem "rubocop", "~> 1.0"
+ gem "rubocop-minitest", "~> 0.35"
+ gem "rubocop-performance", "~> 1.0"
+end
+
+group :test do
+ gem "minitest", "~> 5.0"
+ gem "mocha"
+ gem "simplecov", require: false
+ gem "activejob"
+ gem "actionmailer"
+ gem "activestorage"
+ gem "sqlite3"
+ gem "pg"
+ gem "mysql2"
+ gem "bootsnap", require: false
+ gem "puma"
+ gem "importmap-rails"
+ gem "sprockets-rails"
+ gem "stimulus-rails"
+ gem "turbo-rails"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile
new file mode 100644
index 0000000..0237ae0
--- /dev/null
+++ b/gemfiles/rails_7.2.gemfile
@@ -0,0 +1,36 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 7.2.0"
+
+group :development do
+ gem "appraisal"
+ gem "web-console"
+ gem "standard"
+ gem "rubocop", "~> 1.0"
+ gem "rubocop-minitest", "~> 0.35"
+ gem "rubocop-performance", "~> 1.0"
+end
+
+group :test do
+ gem "minitest", "~> 5.0"
+ gem "mocha"
+ gem "simplecov", require: false
+ gem "activejob"
+ gem "actionmailer"
+ gem "activestorage"
+ gem "sqlite3"
+ gem "pg"
+ gem "mysql2"
+ gem "bootsnap", require: false
+ gem "puma"
+ gem "importmap-rails"
+ gem "sprockets-rails"
+ gem "stimulus-rails"
+ gem "turbo-rails"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile
new file mode 100644
index 0000000..64c4c17
--- /dev/null
+++ b/gemfiles/rails_8.1.gemfile
@@ -0,0 +1,36 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 8.1.0"
+
+group :development do
+ gem "appraisal"
+ gem "web-console"
+ gem "standard"
+ gem "rubocop", "~> 1.0"
+ gem "rubocop-minitest", "~> 0.35"
+ gem "rubocop-performance", "~> 1.0"
+end
+
+group :test do
+ gem "minitest", "~> 5.0"
+ gem "mocha"
+ gem "simplecov", require: false
+ gem "activejob"
+ gem "actionmailer"
+ gem "activestorage"
+ gem "sqlite3"
+ gem "pg"
+ gem "mysql2"
+ gem "bootsnap", require: false
+ gem "puma"
+ gem "importmap-rails"
+ gem "sprockets-rails"
+ gem "stimulus-rails"
+ gem "turbo-rails"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
From e6c60d9c33efc37a9fbb743386819c8557773af4 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Wed, 3 Jun 2026 00:40:18 +0100
Subject: [PATCH 12/15] Rename macros to has_reporting_and_blocking /
has_reportable_content (clean, no aliases) + Report#resolve!/dismiss! +
transparency opt-in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- has_moderation_capabilities -> has_reporting_and_blocking; reportable -> has_reportable_content.
No back-compat aliases — pre-1.0, clean rename. All call sites, tests, dummy app, docs,
CHANGELOG, and comments updated; the non-macro reportable surface (Moderate::Reportable,
reportable_fields, the polymorphic association, reportable: kwargs) is untouched.
- Add Report#resolve!(by:, **) and Report#dismiss!(by:, note:) delegators to
Moderate::Services::ResolveReport so the documented one-liner API is real (+ test).
- Public Art. 24 transparency report is now opt-in: config.transparency_report_enabled
(default false); the controller 404s when disabled.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 6 +-
README.md | 89 ++++++++++---------
.../transparency_reports_controller.rb | 12 +++
docs/compliance.md | 4 +-
docs/configuration.md | 8 +-
.../moderate/templates/initializer.rb | 13 +++
lib/moderate.rb | 8 +-
lib/moderate/configuration.rb | 11 +++
lib/moderate/engine.rb | 4 +-
lib/moderate/macros.rb | 36 ++++----
lib/moderate/models/concerns/actor.rb | 6 +-
lib/moderate/models/concerns/reportable.rb | 12 +--
lib/moderate/models/report.rb | 21 ++++-
lib/moderate/services/intake_report.rb | 2 +-
moderate.gemspec | 4 +-
test/dummy/app/models/comment.rb | 2 +-
test/dummy/app/models/user.rb | 6 +-
test/dummy/config/initializers/moderate.rb | 2 +-
test/integration/transparency_report_test.rb | 3 +
test/macros_test.rb | 10 +--
test/models/moderate/report_test.rb | 2 +-
test/services/moderate/resolve_report_test.rb | 16 ++++
22 files changed, 181 insertions(+), 96 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e342a5b..d449018 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,10 +18,10 @@ Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
### Added
-- **Reporting.** `Moderate::Report` plus the `reportable :fields` macro and
+- **Reporting.** `Moderate::Report` plus the `has_reportable_content :fields` macro and
`Actor#report!(reportable, category:, details:)`. Reports and DSA notices share one model
and one queue (`intake_kind: "community" | "dsa"`).
-- **Blocking.** `Moderate::Block`, the `has_moderation_capabilities` actor macro, and
+- **Blocking.** `Moderate::Block`, the `has_reporting_and_blocking` actor macro, and
`block!` / `unblock!` / `blocks?` / `blocked_by?` / `blocked_with?`. `Moderate.blocked_ids_for(user)`
is the bidirectional single source of truth you compose into feed/search/inbox queries.
Optional `config.on_block` teardown hook runs inside the block transaction.
@@ -65,7 +65,7 @@ Play **aligned** primitives. (First cut ships as `1.0.0.beta1`.)
- The 0.x profanity validator still loads: `validates :field, moderate: true` continues to work
via compatibility shims, so existing apps keep validating. To adopt 1.0, add
- `has_moderation_capabilities` to your user model and `reportable` / `moderates` to your
+ `has_reporting_and_blocking` to your user model and `has_reportable_content` / `moderates` to your
content models, run `rails generate moderate:install`, and migrate.
## [0.1.0] - 2024-11-03
diff --git a/README.md b/README.md
index 4e55017..cd588aa 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,21 @@
-# 🛡️ `moderate` - Trust & Safety for your Rails app (report, block, filter, comply)
+# 🛡️ `moderate` - Let your Rails users report content and block each other (Trust & Safety)
[](https://badge.fury.io/rb/moderate) [](https://github.com/rameerez/moderate/actions)
> [!TIP]
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=moderate)**, 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](https://railsfast.com/?ref=moderate)!
-`moderate` gives your Rails app a complete **Trust & Safety** layer — let users **report** abusive content, **block** each other, **filter** objectionable text and images before they're posted, and run a **moderation queue** your admins actually use. It ships **DSA-aligned primitives** (EU Digital Services Act) and Apple App Store / Google Play UGC mechanisms, so the core reporting, blocking, notice, appeal, transparency, and audit workflows are not scattered through your app.
+`moderate` gives your Rails app a complete **Trust & Safety** system.
-It 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` gives you the mechanisms when you need them, not a legal conclusion that every app must use every surface.
+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).
-It reads like plain English. Make any model reportable:
+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` 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:
```ruby
class Comment < ApplicationRecord
@@ -24,7 +30,7 @@ current_user.block!(@other_user)
current_user.blocks?(@other_user) # => true
```
-Filter content before it's ever saved — one line:
+Filter content before it's ever saved with just one line:
```ruby
class Message < ApplicationRecord
@@ -39,42 +45,10 @@ 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-esque gem** instead of scattered, half-finished, store-rejecting DIY code.
+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 Trust & Safety lives in *admin* surfaces, so the gem ships the **primitives** (models, services, helpers, controller concerns) and lets you **bring your own UI**. It plugs into [`madmin`](https://github.com/excid3/madmin) (or any admin) in minutes — see [Admin & moderation queue](#-admin--the-moderation-queue). It also snaps into the rest of the ecosystem: [`goodmail`](https://github.com/rameerez/goodmail) for decision emails, [`telegrama`](https://github.com/rameerez/telegrama) for admin alerts, and [`noticed`](https://github.com/excid3/noticed) for multi-channel notifications — all through one `notify` hook.
-
----
-
-## 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.
-
-It works standalone, and gets better with the rest of the ecosystem.
-
-## 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`](https://github.com/rameerez/goodmail) / [`noticed`](https://github.com/excid3/noticed) — `moderate` just emits events).
-- The admin UI chrome (that's [`madmin`](https://github.com/excid3/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).
+> `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`](https://github.com/excid3/madmin) (or any admin system) in minutes; see [Admin & moderation queue](#-admin--the-moderation-queue).
---
@@ -114,7 +88,42 @@ class Message < ApplicationRecord
end
```
-That's it — you now have reporting, blocking, filtering, and a moderation queue. Everything below is detail.
+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`](https://github.com/rameerez/goodmail) / [`noticed`](https://github.com/excid3/noticed) — `moderate` just emits events).
+- The admin UI chrome (that's [`madmin`](https://github.com/excid3/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).
---
diff --git a/app/controllers/moderate/transparency_reports_controller.rb b/app/controllers/moderate/transparency_reports_controller.rb
index 0caec60..f7f4738 100644
--- a/app/controllers/moderate/transparency_reports_controller.rb
+++ b/app/controllers/moderate/transparency_reports_controller.rb
@@ -4,6 +4,8 @@ module Moderate
# Public aggregate transparency report for moderation intake, decisions, appeals,
# and automated flags.
class TransparencyReportsController < Moderate::ApplicationController
+ before_action :ensure_transparency_report_enabled!
+
def show
@period_start = 1.year.ago.beginning_of_day
@period_end = Time.current
@@ -24,6 +26,16 @@ def show
private
+ # Hard kill-switch: the public transparency report is OFF unless the host opts
+ # in with `config.transparency_report_enabled = true`. Raising RoutingError makes
+ # the mounted route behave as if it doesn't exist (a 404), same pattern as the
+ # notice/appeal form kill-switches.
+ def ensure_transparency_report_enabled!
+ return if Moderate.config.transparency_report_enabled
+
+ raise ActionController::RoutingError, "Moderate transparency report is disabled (config.transparency_report_enabled = false)"
+ end
+
def median_seconds(pairs)
values = pairs.filter_map { |created_at, resolved_at| resolved_at && created_at ? (resolved_at - created_at).to_i : nil }.sort
return 0 if values.empty?
diff --git a/docs/compliance.md b/docs/compliance.md
index e0d0618..5d713ec 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -133,7 +133,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is `reportable` can be reported. | **[gem]** | `test/models/reportable_test.rb` |
-| In-app **reporting** of objectionable **users**. | A user model with `has_moderation_capabilities` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
+| In-app **reporting** of objectionable **users**. | A user model with `has_reporting_and_blocking` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/block_test.rb` |
| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/models/blocked_ids_scope_test.rb` |
| A method to **moderate UGC** (a real review surface, not just intake). | `Moderate::Report.pending` / `Moderate::Flag.pending` give admins the queue; `resolve!`/`dismiss!`/`remove_content`/`ban_user` are the audited actions. (BYOUI — you bind these to your admin; see [`docs/madmin.md`](madmin.md).) | **[gem + you]** | `test/services/resolve_test.rb` |
@@ -141,7 +141,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Users **accept terms / acceptable-use** before contributing UGC. | This is your signup/terms gate — `moderate` doesn't own it — but, as with Apple, your acceptable-use policy should enumerate the **community-report categories** so the terms and the report buttons describe the same prohibited behavior. | **[you]** | manual: terms acceptance in your onboarding |
> [!NOTE]
-> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_moderation_capabilities` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
+> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_reporting_and_blocking` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
---
diff --git a/docs/configuration.md b/docs/configuration.md
index 27607fc..a37884d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -62,7 +62,7 @@ The model that **acts** in your Trust & Safety system: it reports, it blocks, it
```ruby
class User < ApplicationRecord
- has_moderation_capabilities # gains report!/block!/blocks?/blocked_with?…
+ has_reporting_and_blocking # gains report!/block!/blocks?/blocked_with?…
# include Moderate::Actor # the documented, exactly-equivalent include form
end
```
@@ -294,11 +294,11 @@ Config sets defaults; the model macros consume them. The two halves of the API:
| In the model | In the initializer | What it controls |
| --- | --- | --- |
-| `has_moderation_capabilities` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
-| `reportable :title, :description` (or `include Moderate::Reportable`) | — (auto-discovered) | Which content is reportable, and which fields |
+| `has_reporting_and_blocking` (or `include Moderate::Actor`) | `config.user_class` | Who can report/block and be reported/banned |
+| `has_reportable_content :title, :description` (or `include Moderate::Reportable`) | — (auto-discovered) | Which content is reportable, and which fields |
| `moderates :body, with:, mode:` | `config.default_filter_mode`, `config.filter_adapter`, `config.filter "…"` | Pre-publication filtering per field |
-Both sugar macros have an exactly-equivalent `include` form for include-purists — `has_moderation_capabilities` ⇔ `include Moderate::Actor`, `reportable` ⇔ `include Moderate::Reportable`. They compile to the same thing.
+Both sugar macros have an exactly-equivalent `include` form for include-purists — `has_reporting_and_blocking` ⇔ `include Moderate::Actor`, `has_reportable_content` ⇔ `include Moderate::Reportable`. They compile to the same thing.
---
diff --git a/lib/generators/moderate/templates/initializer.rb b/lib/generators/moderate/templates/initializer.rb
index dfb3661..d054220 100644
--- a/lib/generators/moderate/templates/initializer.rb
+++ b/lib/generators/moderate/templates/initializer.rb
@@ -160,6 +160,19 @@
# controller.request.user_agent.to_s.match?(/Hotwire Native/i)
# }
+ # ==========================================================================
+ # PUBLIC TRANSPARENCY REPORT (DSA Art. 24) — opt-in
+ # ==========================================================================
+ #
+ # OFF by default. A *live* transparency portal is not itself a legal requirement:
+ # the DSA obligation is to *publish* a report at least annually (a static page/file
+ # is fine), and micro/small enterprises are exempt from the transparency tier
+ # entirely (Art. 15(2) / Art. 19). Turn it on to expose the mounted
+ # `/transparency` page; left off, that route 404s and the counts are never
+ # published. The aggregation is still queryable in code so you can build your own.
+ #
+ # config.transparency_report_enabled = true
+
# ==========================================================================
# SIGNED LINKS — purposes for the signed Global IDs in emails & notices
# ==========================================================================
diff --git a/lib/moderate.rb b/lib/moderate.rb
index 8c8eb7f..fb5fe10 100644
--- a/lib/moderate.rb
+++ b/lib/moderate.rb
@@ -17,7 +17,7 @@
require_relative "moderate/event"
require_relative "moderate/configuration"
-# The class-level DSL (has_moderation_capabilities / reportable / moderates). Required here,
+# The class-level DSL (has_reporting_and_blocking / has_reportable_content / moderates). Required here,
# eagerly, because the engine's `moderate.active_record` initializer does
# `extend Moderate::Macros` inside an `on_load(:active_record)` block — the constant
# must already be defined by the time that hook fires. It's a plain module that only
@@ -79,7 +79,7 @@ def configure
# swaps `config.user_class` doesn't see a stale lazily-memoized class.
#
# IMPORTANT: we do NOT clear the reportable REGISTRY here. Reportable classes are
- # discovered once, at MODEL LOAD time (the `reportable` macro / `include
+ # discovered once, at MODEL LOAD time (the `has_reportable_content` macro / `include
# Moderate::Reportable` runs `Moderate.register_reportable(self)` on inclusion).
# In a booted app (and the eager-loaded test suite) the models load exactly once,
# so wiping the registry on every `reset!` would leave `Moderate.reportable_classes`
@@ -113,10 +113,10 @@ def user_class
# --- Reportable registry --------------------------------------------------
# Auto-discovered set of classes that declared themselves reportable (via the
- # `reportable` macro or `include Moderate::Reportable`). The Reportable concern
+ # `has_reportable_content` macro or `include Moderate::Reportable`). The Reportable concern
# calls `Moderate.register_reportable(self)` on inclusion, so there's NO manual
# registry to maintain — exactly what the README promises ("Reportable classes
- # are auto-discovered from the `reportable` macro — no manual registry.").
+ # are auto-discovered from the `has_reportable_content` macro — no manual registry.").
#
# Stored as a Set of STRING class names (not Class objects) so we never pin a
# class in memory across a Zeitwerk reload in development; we constantize on read.
diff --git a/lib/moderate/configuration.rb b/lib/moderate/configuration.rb
index 2d9b9bb..9abc143 100644
--- a/lib/moderate/configuration.rb
+++ b/lib/moderate/configuration.rb
@@ -76,6 +76,7 @@ def flag? = mode == :flag
:notice_captcha_verifier, :notice_guard, :notice_human_verification_skip_if,
:appeal_form_enabled, :appeal_rate_limit, :appeal_guard,
:appeal_human_verification_skip_if, :appeal_return_path,
+ :transparency_report_enabled,
:signed_gid_purposes
def initialize
@@ -149,6 +150,16 @@ def initialize
@appeal_human_verification_skip_if = nil
@appeal_return_path = "/"
+ # The public Art. 24 transparency report. OFF by default — opt in with
+ # `config.transparency_report_enabled = true`. A *live* transparency portal is
+ # not itself a legal requirement: the DSA obligation is to *publish* a report at
+ # least annually (a static page/file is fine), and micro/small enterprises are
+ # exempt from the transparency tier entirely (Art. 15(2) / Art. 19). So we don't
+ # publicly expose moderation counts unless the host explicitly turns it on. When
+ # off, the mounted `/transparency` route 404s; the aggregation stays queryable in
+ # code so a host can still build/publish its own report.
+ @transparency_report_enabled = false
+
@signed_gid_purposes = %i[appeal confirm_notice unsubscribe]
end
diff --git a/lib/moderate/engine.rb b/lib/moderate/engine.rb
index c63c599..6b40947 100644
--- a/lib/moderate/engine.rb
+++ b/lib/moderate/engine.rb
@@ -115,8 +115,8 @@ class Engine < ::Rails::Engine
#
# `ActiveSupport.on_load(:active_record)` defers until ActiveRecord::Base is
# actually defined, so we never force-load AR at boot and we play nicely with
- # the host's load order. Once it fires, every model gains `has_moderation_capabilities`,
- # `reportable`, and `moderates` as class methods (the macros that lazily
+ # the host's load order. Once it fires, every model gains `has_reporting_and_blocking`,
+ # `has_reportable_content`, and `moderates` as class methods (the macros that lazily
# include Moderate::Actor / Moderate::Reportable / Moderate::ContentFilterable).
#
# `Moderate::Macros` is require_relative'd by the spine, so the constant resolves
diff --git a/lib/moderate/macros.rb b/lib/moderate/macros.rb
index 27ed382..e4b670a 100644
--- a/lib/moderate/macros.rb
+++ b/lib/moderate/macros.rb
@@ -12,18 +12,19 @@
#
# This is safe because the macro methods below only REFERENCE the concern constants
# inside their bodies (`include Moderate::Actor`), which run when a host model calls
-# `has_moderation_capabilities`/`reportable`/`moderates` — long after boot, when the autoloader
-# is fully wired. So Zeitwerk autoloads each concern lazily on first use.
+# `has_reporting_and_blocking`/`has_reportable_content`/`moderates` — long after
+# boot, when the autoloader is fully wired. So Zeitwerk autoloads each concern
+# lazily on first use.
module Moderate
# The class-level DSL the gem adds to every ActiveRecord model.
#
# The engine does `ActiveSupport.on_load(:active_record) { extend Moderate::Macros }`,
- # so `has_moderation_capabilities`, `reportable`, and `moderates` become class methods on
- # ActiveRecord::Base — readable plain-English declarations that sit alongside the
- # rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`):
+ # so `has_reporting_and_blocking`, `has_reportable_content`, and `moderates` become
+ # class methods on ActiveRecord::Base — readable plain-English declarations that
+ # sit alongside the rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`):
#
- # class User < ApplicationRecord; has_moderation_capabilities; end
- # class Listing < ApplicationRecord; reportable :title, :description; end
+ # class User < ApplicationRecord; has_reporting_and_blocking; end
+ # class Listing < ApplicationRecord; has_reportable_content :title, :description; end
# class Message < ApplicationRecord; moderates :body, mode: :flag; end
#
# Each macro is exact sugar for an `include` + a declaration — the README
@@ -32,23 +33,26 @@ module Moderate
# forward to that concern's declaration method. All behavior lives in the
# concerns, never here.
module Macros
- # `has_moderation_capabilities` — make this model an ACTOR (and, since a user is
- # usually itself reportable, a reportable too): report!/block!/unblock!/blocks?/
- # blocked_with?, the block & report associations, and the be-banned target.
+ # `has_reporting_and_blocking` — make this model an ACTOR in the Trust & Safety
+ # system: it can report content/users and block/unblock other users
+ # (report!/block!/unblock!/blocks?/blocked_by?/blocked_with?, plus the block &
+ # report associations), and — since the actor is usually itself reportable and is
+ # the be-banned target — it's also made reportable. One macro, both halves.
#
# Equivalent to `include Moderate::Actor`. Idempotent: re-declaring (or both
# macro + explicit include) won't double-include.
- def has_moderation_capabilities
+ def has_reporting_and_blocking
include Moderate::Actor unless include?(Moderate::Actor)
end
- # `reportable(*fields)` — make this content reportable, optionally narrowing to
- # specific fields. Bare `reportable` (no fields) means "the whole record is
- # reportable" (the field whitelist stays empty, and a blank reported_field is
- # then allowed — see Reportable#reportable_field_allowed?).
+ # `has_reportable_content(*fields)` — mark this model as REPORTABLE content,
+ # optionally narrowing to specific fields. Bare `has_reportable_content` (no
+ # fields) means "the whole record is reportable" (the field whitelist stays
+ # empty, and a blank reported_field is then allowed — see
+ # Reportable#reportable_field_allowed?).
#
# Equivalent to `include Moderate::Reportable` + `reportable_fields(*fields)`.
- def reportable(*fields)
+ def has_reportable_content(*fields)
include Moderate::Reportable unless include?(Moderate::Reportable)
reportable_fields(*fields) if fields.any?
self
diff --git a/lib/moderate/models/concerns/actor.rb b/lib/moderate/models/concerns/actor.rb
index 286c028..6bb5790 100644
--- a/lib/moderate/models/concerns/actor.rb
+++ b/lib/moderate/models/concerns/actor.rb
@@ -3,7 +3,7 @@
module Moderate
# The "person who acts" in the Trust & Safety system: the model that reports
# other content, blocks other actors, gets reported, gets banned. Backs the
- # `has_moderation_capabilities` macro (and its documented equivalent,
+ # `has_reporting_and_blocking` macro (and its documented equivalent,
# `include Moderate::Actor`).
#
# This is the one model the gem treats as the actor/identity, configured via
@@ -20,7 +20,7 @@ module Moderate
module Actor
extend ActiveSupport::Concern
- # A user is also reportable. Pulling Reportable in here means `has_moderation_capabilities`
+ # A user is also reportable. Pulling Reportable in here means `has_reporting_and_blocking`
# alone gives you both halves (act AND be-acted-on) without a second macro.
include Moderate::Reportable
@@ -60,7 +60,7 @@ module Actor
# --- Reporting ------------------------------------------------------------
# File a report from this actor against a piece of content (or another actor —
- # a user with `has_moderation_capabilities` is itself reportable).
+ # a user with `has_reporting_and_blocking` is itself reportable).
#
# current_user.report!(@message, category: :harassment, details: "...")
# current_user.report!(@other_user, category: :impersonation)
diff --git a/lib/moderate/models/concerns/reportable.rb b/lib/moderate/models/concerns/reportable.rb
index 9f4900d..d1da67e 100644
--- a/lib/moderate/models/concerns/reportable.rb
+++ b/lib/moderate/models/concerns/reportable.rb
@@ -36,7 +36,7 @@ module Moderate
# https://eur-lex.europa.eu/eli/reg/2022/2065/oj
#
# The documented include form is `include Moderate::Reportable` +
- # `reportable_fields :a, :b`; the `reportable :a, :b` macro is exact sugar.
+ # `reportable_fields :a, :b`; the `has_reportable_content :a, :b` macro is exact sugar.
module Reportable
extend ActiveSupport::Concern
@@ -59,7 +59,7 @@ module Reportable
# Self-register in the gem's reportable registry the moment the concern is
# included, so `Moderate.reportable_classes` is auto-discovered with NO
# manual list to maintain (README: "Reportable classes are auto-discovered
- # from the `reportable` macro — no manual registry."). We register the class
+ # from the `has_reportable_content` macro — no manual registry."). We register the class
# NAME (the registry stores strings and constantizes lazily) so we never pin
# the class across a Zeitwerk reload in development.
Moderate.register_reportable(self)
@@ -85,11 +85,11 @@ def reportable_fields(*fields)
# is valid whether or not the model declared specific reportable fields. (A
# user tapping "Report this comment" doesn't name a field; only the public DSA
# notice / a field-targeted in-app flow does.) So a Comment that declares
- # `reportable :body` can still be reported as a whole with a nil field.
+ # `has_reportable_content :body` can still be reported as a whole with a nil field.
#
# - A NAMED field must be in the whitelist. With no fields declared, the
# whitelist is empty, so any named field is rejected (there's nothing to
- # target field-by-field on a bare-`reportable` record).
+ # target field-by-field on a bare-`has_reportable_content` record).
#
# This is the authorization gate the Report model and the report controller both
# consult before accepting a `reported_field`.
@@ -106,7 +106,7 @@ def reportable_field_allowed?(field)
# NO default: a model that can be reported MUST tell the gem who's behind it,
# because guessing wrong here means notifying or banning the wrong person.
# We raise a NotImplementedError naming the class so the omission is loud at
- # the first report, not silent. (A `User` model with `has_moderation_capabilities` is itself
+ # the first report, not silent. (A `User` model with `has_reporting_and_blocking` is itself
# reportable and returns `self` — see Moderate::Actor.)
def reported_owner
raise NotImplementedError,
@@ -170,7 +170,7 @@ def removable_reported_field?(_field)
#
# The `moderate_report_link` helper renders nothing when this is false, and the
# report controller redirects. Hosts can override for richer rules. (A User with
- # `has_moderation_capabilities` overrides this in Moderate::Actor to compare ids directly,
+ # `has_reporting_and_blocking` overrides this in Moderate::Actor to compare ids directly,
# since a user IS its own owner.)
def report_visible_to?(viewer, field:)
return false unless reportable_field_allowed?(field)
diff --git a/lib/moderate/models/report.rb b/lib/moderate/models/report.rb
index 348970e..c75f73a 100644
--- a/lib/moderate/models/report.rb
+++ b/lib/moderate/models/report.rb
@@ -16,7 +16,7 @@ module Moderate
# automated-processing disclosure (DSA Art. 16(6)/17(3)(c)), the appeal window
# (DSA Art. 20), and a signed-GlobalID locator for the public flows. It stays
# host-agnostic: who "owns" reported content, how content is snapshotted, and
- # what fields may be reported are all answered by the polymorphic `reportable`
+ # what fields may be reported are all answered by the polymorphic `has_reportable_content`
# (through the `Moderate::Reportable` interface), never by anything domain-specific.
class Report < ApplicationRecord
self.table_name = "moderate_reports"
@@ -337,6 +337,23 @@ def close_redress_window!(at: resolved_at || Time.current)
update_column(:appeal_deadline_at, at + APPEAL_WINDOW) if appeal_deadline_at.blank?
end
+ # Resolve this report — model-level sugar over Moderate::Services::ResolveReport,
+ # so a host can write `report.resolve!(by: moderator, remove_content: true,
+ # ban_user: true, note: "…")` instead of constructing the service. The service is
+ # where the real work lives (row lock + re-check open, in-transaction enforcement,
+ # out-of-transaction notify, statement-of-reasons + appeal-window stamping); this
+ # only forwards. `by:` is the moderator; the rest (`note:`, `remove_content:`,
+ # `ban_user:`, `resolution_basis:`) pass straight through.
+ def resolve!(by:, **options)
+ Moderate::Services::ResolveReport.new(self, by: by).resolve!(**options)
+ end
+
+ # Dismiss this report (no action taken) — sugar over
+ # Moderate::Services::ResolveReport#dismiss!.
+ def dismiss!(by:, note:)
+ Moderate::Services::ResolveReport.new(self, by: by).dismiss!(note: note)
+ end
+
# DSA Art. 17(3)(c): did automated means (a wordlist/image/remote classifier)
# participate in surfacing or deciding this report? The decision email reads this
# to truthfully state whether automation was used. We treat the disclosure as
@@ -482,7 +499,7 @@ def reporter_cannot_report_self
end
# The reported field must be one the reportable actually allows to be reported
- # (declared via `reportable :title, :description`). We ask the reportable via its
+ # (declared via `has_reportable_content :title, :description`). We ask the reportable via its
# `reportable_field_allowed?` predicate; if it doesn't expose one (a record that
# isn't a managed reportable), we don't constrain the field.
def reportable_field_must_be_allowed
diff --git a/lib/moderate/services/intake_report.rb b/lib/moderate/services/intake_report.rb
index 4599483..a580177 100644
--- a/lib/moderate/services/intake_report.rb
+++ b/lib/moderate/services/intake_report.rb
@@ -17,7 +17,7 @@ module Services
# two front doors.
#
# This object is HOST-AGNOSTIC: it never references a concrete content type. The
- # `reportable` is any `Moderate::Reportable` record (polymorphic), the actor is
+ # `has_reportable_content` is any `Moderate::Reportable` record (polymorphic), the actor is
# whatever `Moderate.user_class` resolves to, and notification/audit go through
# the configured hooks — never a hard-wired mailer.
class IntakeReport
diff --git a/moderate.gemspec b/moderate.gemspec
index 679c072..94013b9 100644
--- a/moderate.gemspec
+++ b/moderate.gemspec
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
spec.authors = ["rameerez"]
spec.email = ["rubygems@rameerez.com"]
- spec.summary = "Trust & Safety and content moderation for Rails: report abusive content, block users, filter objectionable text and images, and run an audited moderation queue — with DSA-aligned and App Store / Google Play UGC primitives."
- spec.description = "moderate is a complete Trust & Safety and content moderation engine for Ruby on Rails apps with user-generated content (UGC) — social apps, marketplaces, dating, communities, forums, comments, reviews, and chat. It bundles the four things every UGC app needs behind one data model and one set of hooks: abuse reporting (report posts, comments, profiles, listings, messages, and other users), bidirectional user blocking behind a single enforced source of truth, pre-publication content filtering for profanity, slurs, hate, spam, harassment, and objectionable or NSFW text and images in off/block/flag modes, and an audited moderation queue with locked resolve/dismiss/remove-content/ban decisions, internal appeals, and statement-of-reasons notifications. Filtering uses a tiny classify(value) => Result adapter contract: a fast, offline, multilingual wordlist/profanity/bad-word blocklist ships as the zero-dependency default, and you bring your own classifier (OpenAI omni-moderation, AWS Rekognition image/NSFW detection, Google Perspective, or self-hosted) as an optional reference adapter, never a forced dependency. moderate also ships primitives aligned with the EU Digital Services Act (DSA notice-and-action, statement of reasons, appeals, transparency) and with the Apple App Store (Guideline 1.2) and Google Play user-generated-content review rules that get apps rejected without report/block/filter — the mechanisms, not a compliance certificate. It is a mountable, UI-agnostic Rails engine with no hard dependencies beyond ActiveRecord, ActiveSupport, Railties, and GlobalID, and optionally auto-integrates with madmin, goodmail, telegrama, noticed, and rails_cloudflare_turnstile — a modern, full-stack alternative to single-purpose Rails profanity filters like obscenity and profanity-filter."
+ spec.summary = "Let your Rails users report content and block each other (Trust & Safety)"
+ spec.description = "moderate is a complete Trust & Safety and content moderation engine for Ruby on Rails apps. 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, 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). Any app with user-generated content (UGC) needs this: social apps, marketplaces, dating, communities, forums, comments, reviews, chat, etc. The moderate gem bundles the four things every UGC app needs behind one data model and one set of hooks: abuse reporting (report posts, comments, profiles, listings, messages, and other users), bidirectional user blocking behind a single enforced source of truth, pre-publication content filtering for profanity, slurs, hate, spam, harassment, and objectionable or NSFW text and images in off/block/flag modes, and an audited moderation queue with locked resolve/dismiss/remove-content/ban decisions, internal appeals, and statement-of-reasons notifications. Filtering uses a tiny classify(value) => Result adapter contract: a fast, offline, multilingual wordlist/profanity/bad-word blocklist ships as the zero-dependency default, and you bring your own classifier (OpenAI omni-moderation, AWS Rekognition image/NSFW detection, Google Perspective, or self-hosted) as an optional reference adapter, not a forced dependency. moderate also ships primitives aligned with the EU Digital Services Act (DSA notice-and-action, statement of reasons, appeals, transparency) and with the Apple App Store (Guideline 1.2) and Google Play user-generated-content review rules that get apps rejected without report/block/filter. It is a mountable, UI-agnostic Rails engine with no hard dependencies beyond normal Rails stuff."
spec.homepage = "https://github.com/rameerez/moderate"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"
diff --git a/test/dummy/app/models/comment.rb b/test/dummy/app/models/comment.rb
index 919357b..778d6a7 100644
--- a/test/dummy/app/models/comment.rb
+++ b/test/dummy/app/models/comment.rb
@@ -9,7 +9,7 @@
# objectionable body is rejected synchronously with a
# validation error (errors.add(:body, :objectionable_content)).
class Comment < ApplicationRecord
- reportable :body
+ has_reportable_content :body
moderates :body
# Every comment belongs to a user; that user is who a decision notifies and a ban
diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb
index 8b6362b..d3a2e34 100644
--- a/test/dummy/app/models/user.rb
+++ b/test/dummy/app/models/user.rb
@@ -2,7 +2,7 @@
# The dummy host's ACTOR model — the `config.user_class`.
#
-# `has_moderation_capabilities` (the macro the engine adds to ActiveRecord::Base) makes a User
+# `has_reporting_and_blocking` (the macro the engine adds to ActiveRecord::Base) makes a User
# able to report, block/unblock, be reported, and be banned; because Actor pulls in
# Reportable, a User is ALSO reportable (Apple 1.2 / Google Play UGC both require
# reporting AND blocking *users*, not just content). `reportable :name` narrows the
@@ -10,8 +10,8 @@
# whitelist (a report naming `:name` is allowed; one naming an undeclared field is
# rejected by Moderate::Report's reportable_field_must_be_allowed validation).
class User < ApplicationRecord
- has_moderation_capabilities
- reportable :name
+ has_reporting_and_blocking
+ has_reportable_content :name
# The initializer declares `config.filter "User", :name, mode: :flag`, so a User
# is also a Moderate::ContentFilterable target on `:name`. We include the concern
diff --git a/test/dummy/config/initializers/moderate.rb b/test/dummy/config/initializers/moderate.rb
index d31b483..ff24eb9 100644
--- a/test/dummy/config/initializers/moderate.rb
+++ b/test/dummy/config/initializers/moderate.rb
@@ -25,7 +25,7 @@
# shared setup is the natural place to re-point the hooks at ModerateTestRecorder).
# This file documents the canonical wiring; the suite mirrors it post-reset.
Moderate.configure do |config|
- # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / has_moderation_capabilities).
+ # WHO ARE YOUR USERS — the actor model (include Moderate::Actor / has_reporting_and_blocking).
# Stored as a string, constantized lazily, so this works even though User isn't
# loaded yet at boot.
config.user_class = "User"
diff --git a/test/integration/transparency_report_test.rb b/test/integration/transparency_report_test.rb
index 03e24b4..0f975ab 100644
--- a/test/integration/transparency_report_test.rb
+++ b/test/integration/transparency_report_test.rb
@@ -4,6 +4,9 @@
class TransparencyReportTest < ActionDispatch::IntegrationTest
test "public transparency report renders aggregate moderation counters" do
+ # The public report is opt-in (off by default — see config.transparency_report_enabled).
+ Moderate.config.transparency_report_enabled = true
+
Moderate::Report.create!(
notifier_name: "Notice Sender",
notifier_email: "notice@example.com",
diff --git a/test/macros_test.rb b/test/macros_test.rb
index 645369f..4f0ac12 100644
--- a/test/macros_test.rb
+++ b/test/macros_test.rb
@@ -3,7 +3,7 @@
require "test_helper"
# Tests for the class-level DSL the engine adds to every ActiveRecord model:
-# `has_moderation_capabilities`, `reportable`, and `moderates` (Moderate::Macros, extended onto
+# `has_reporting_and_blocking`, `reportable`, and `moderates` (Moderate::Macros, extended onto
# ActiveRecord::Base via `ActiveSupport.on_load(:active_record)`).
#
# Two angles:
@@ -14,15 +14,15 @@
# assertion sees the policy the macro just created rather than a boot-time one
# that reset! wiped.
class MacrosTest < ActiveSupport::TestCase
- # --- has_moderation_capabilities (Actor + Reportable) ----------------------
+ # --- has_reporting_and_blocking (Actor + Reportable) ----------------------
- test "has_moderation_capabilities includes Moderate::Actor (and Reportable, since a user is reportable)" do
+ test "has_reporting_and_blocking includes Moderate::Actor (and Reportable, since a user is reportable)" do
assert User.include?(Moderate::Actor)
# Actor pulls in Reportable — Apple 1.2 / Play UGC require reporting USERS too.
assert User.include?(Moderate::Reportable)
end
- test "has_moderation_capabilities gives an actor the report!/block! surface" do
+ test "has_reporting_and_blocking gives an actor the report!/block! surface" do
actor = create_user
%i[report! block! unblock! blocks? blocked_by? blocked_with?].each do |method|
assert_respond_to actor, method, "expected actor to respond to ##{method}"
@@ -157,7 +157,7 @@ def anonymous_reportable_class
Class.new(ApplicationRecord) do
self.table_name = "comments"
def self.name = "AnonymousBareReportable"
- reportable # no fields → whole-record reportable
+ has_reportable_content # no fields → whole-record reportable
end
end
diff --git a/test/models/moderate/report_test.rb b/test/models/moderate/report_test.rb
index a0d325e..146e9be 100644
--- a/test/models/moderate/report_test.rb
+++ b/test/models/moderate/report_test.rb
@@ -51,7 +51,7 @@ class ReportTest < ActiveSupport::TestCase
end
test "reportable classes are auto-discovered from the reportable macro" do
- # User (has_moderation_capabilities -> reportable) and Comment (reportable :body) both
+ # User (has_reporting_and_blocking -> reportable) and Comment (reportable :body) both
# self-registered on inclusion — no manual registry.
assert_includes Moderate.reportable_classes, User
assert_includes Moderate.reportable_classes, Comment
diff --git a/test/services/moderate/resolve_report_test.rb b/test/services/moderate/resolve_report_test.rb
index 57420c5..febe064 100644
--- a/test/services/moderate/resolve_report_test.rb
+++ b/test/services/moderate/resolve_report_test.rb
@@ -28,6 +28,22 @@ class ResolveReportTest < ActiveSupport::TestCase
ModerateTestRecorder.clear
end
+ test "Report#resolve! and #dismiss! delegate to the service (the README's plain-English API)" do
+ moderator = User.create!(name: "Mod", email: "mod-delegate@example.com")
+ author = User.create!(name: "Author", email: "author-delegate@example.com")
+ comment = Comment.create!(user: author, body: "ok body")
+
+ report = create_report(reportable: comment, field: "body")
+ report.resolve!(by: moderator, remove_content: true, ban_user: true, note: "Hate speech")
+ report.reload
+ assert_equal "actioned", report.status
+ assert_equal moderator, report.resolved_by
+
+ another = create_report(reportable: comment, field: "body")
+ another.dismiss!(by: moderator, note: "No violation")
+ assert_equal "dismissed", another.reload.status
+ end
+
test "resolving with actions removes content, bans the owner, audits, and fires both decision events" do
moderator = User.create!(name: "Mod", email: "mod@example.com")
author = User.create!(name: "Author", email: "author@example.com")
From 10afdda33bcee93a16fa45112d90bae4c9a543f6 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Wed, 3 Jun 2026 00:47:29 +0100
Subject: [PATCH 13/15] docs: reflect current API + make documented one-liners
real
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- README/docs updated to the renamed macros (has_reporting_and_blocking /
has_reportable_content) and the transparency-report opt-in.
- Make the documented plain-English admin API real: Appeal#uphold!(by:, note:) /
Appeal#reject!(by:, note:) delegate to Moderate::Services::ResolveAppeal (matching
Report#resolve!/#dismiss!); add tests.
- Add Moderate.transparency(from:, to:) — the DSA Art. 24 aggregation the docs
reference and a host needs to publish its own report now that the public page is
opt-in; the controller renders it; compliance.md citations fixed; add a test.
- compliance.md: drop adjective backticks on 'reportable', repoint phantom test
citations at the real transparency test.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 22 ++++++-------
.../transparency_reports_controller.rb | 24 +++-----------
docs/compliance.md | 18 +++++-----
lib/moderate.rb | 33 +++++++++++++++++++
lib/moderate/models/appeal.rb | 13 ++++++++
test/integration/transparency_report_test.rb | 24 ++++++++++++++
test/services/moderate/resolve_appeal_test.rb | 13 ++++++++
7 files changed, 107 insertions(+), 40 deletions(-)
diff --git a/README.md b/README.md
index cd588aa..9f61ee0 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ If you have an app where users can upload / generate content or send messages to
```ruby
class Comment < ApplicationRecord
- reportable
+ has_reportable_content
end
```
@@ -79,12 +79,12 @@ end
```ruby
class User < ApplicationRecord
- has_moderation_capabilities # can report, block, be blocked, be banned
+ has_reporting_and_blocking # can report, block, be blocked, be banned
end
class Message < ApplicationRecord
- reportable # can be reported
- moderates :body # …and filtered before it's saved
+ has_reportable_content # can be reported
+ moderates :body # …and filtered before it's saved
end
```
@@ -129,11 +129,11 @@ Typical offending content include categories like these, all covered by the `mod
## 🧑🤝🧑 Actors: report & block
-Add `has_moderation_capabilities` to your user model (or any model that acts on behalf of a person):
+Add `has_reporting_and_blocking` to your user model (or any model that acts on behalf of a person):
```ruby
class User < ApplicationRecord
- has_moderation_capabilities
+ has_reporting_and_blocking
end
```
@@ -167,11 +167,11 @@ current_user.report!(@user, category: :impersonation)
## 🚩 Reportable content
-Declare what can be reported with one `reportable` line — the fields are optional (omit them to report the whole record):
+Declare what can be reported with one `has_reportable_content` line — the fields are optional (omit them to report the whole record):
```ruby
class Listing < ApplicationRecord
- reportable :title, :description
+ has_reportable_content :title, :description
# Tell moderate how to present & clean this content when a moderator acts:
def moderation_label = "Listing #{id}"
@@ -200,7 +200,7 @@ Because `moderate` is UI-agnostic, it does not render a built-in "under 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 `/notices/new`, `/notices`, `/appeals/new`, `/appeals`, and `/transparency` — where `` 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 `reportable` line — the intake, queue, snapshot, and admin code never change.
+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`
@@ -345,7 +345,7 @@ The full event vocabulary: `report_received`, `report_decision`, `affected_user_
- **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`](https://github.com/instrumentl/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`](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).
+- **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.
- **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`](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.
@@ -380,7 +380,7 @@ Moderate.configure do |config|
end
```
-Reportable classes are auto-discovered from the `reportable` macro (or `include Moderate::Reportable`) — no manual registry.
+Reportable classes are auto-discovered from the `has_reportable_content` macro (or `include Moderate::Reportable`) — no manual registry.
## Upgrading from 0.x
diff --git a/app/controllers/moderate/transparency_reports_controller.rb b/app/controllers/moderate/transparency_reports_controller.rb
index f7f4738..da9078d 100644
--- a/app/controllers/moderate/transparency_reports_controller.rb
+++ b/app/controllers/moderate/transparency_reports_controller.rb
@@ -9,19 +9,10 @@ class TransparencyReportsController < Moderate::ApplicationController
def show
@period_start = 1.year.ago.beginning_of_day
@period_end = Time.current
- reports = Moderate::Report.where(created_at: @period_start..@period_end)
- appeals = Moderate::Appeal.where(created_at: @period_start..@period_end)
- flags = Moderate::Flag.where(created_at: @period_start..@period_end)
-
- @summary = {
- notices_by_intake: reports.group(:intake_kind).count,
- dsa_notices_by_legal_reason: reports.where(intake_kind: "dsa").group(:legal_reason).count,
- actions_by_basis: reports.where.not(resolved_at: nil).group(:resolution_basis).count,
- automated_flags_by_source: flags.group(:source).count,
- appeals_by_status: appeals.group(:status).count,
- median_notice_action_seconds: median_seconds(reports.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)),
- median_appeal_action_seconds: median_seconds(appeals.where.not(resolved_at: nil).pluck(:created_at, :resolved_at))
- }
+ # The aggregation is a public facade method so a host that keeps this page off
+ # (it's opt-in) can still call `Moderate.transparency(from:, to:)` to publish
+ # its own report. The view renders the same hash either way.
+ @summary = Moderate.transparency(from: @period_start, to: @period_end)
end
private
@@ -35,12 +26,5 @@ def ensure_transparency_report_enabled!
raise ActionController::RoutingError, "Moderate transparency report is disabled (config.transparency_report_enabled = false)"
end
-
- def median_seconds(pairs)
- values = pairs.filter_map { |created_at, resolved_at| resolved_at && created_at ? (resolved_at - created_at).to_i : nil }.sort
- return 0 if values.empty?
-
- values[values.length / 2]
- end
end
end
diff --git a/docs/compliance.md b/docs/compliance.md
index 5d713ec..cc423ef 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -90,11 +90,11 @@ The statement must include, at minimum: the **restriction imposed** (and its sco
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| Count of **notices received** (by type/ground). | `Moderate.transparency` aggregates `moderate_reports` by `intake_kind` and `legal_reason`/`category`. | **[gem]** | `test/services/transparency_counts_test.rb` |
-| Count of **actions taken** (removals, bans, dismissals). | The same aggregation tallies resolutions by action and dismissals. | **[gem]** | `test/services/transparency_counts_test.rb` |
-| **Median handling time** (notice → decision). | Computed from each report's received-at vs decided-at timestamps. | **[gem]** | `test/services/transparency_timing_test.rb` |
-| Use of **automated means** in moderation. | Counts of decisions acting on auto-`Moderate::Flag`s vs human reports, from the `source` column. | **[gem]** | `test/services/transparency_automated_test.rb` |
-| **Appeals** received and their **outcomes** (upheld / rejected). | Aggregation over `moderate_appeals` by status. | **[gem]** | `test/services/transparency_appeals_test.rb` |
+| Count of **notices received** (by type/ground). | `Moderate.transparency` aggregates `moderate_reports` by `intake_kind` and `legal_reason`/`category`. | **[gem]** | `test/integration/transparency_report_test.rb` |
+| Count of **actions taken** (removals, bans, dismissals). | The same aggregation tallies resolutions by action and dismissals. | **[gem]** | `test/integration/transparency_report_test.rb` |
+| **Median handling time** (notice → decision). | Computed from each report's received-at vs decided-at timestamps. | **[gem]** | `test/integration/transparency_report_test.rb` |
+| Use of **automated means** in moderation. | Counts of decisions acting on auto-`Moderate::Flag`s vs human reports, from the `source` column. | **[gem]** | `test/integration/transparency_report_test.rb` |
+| **Appeals** received and their **outcomes** (upheld / rejected). | Aggregation over `moderate_appeals` by status. | **[gem]** | `test/integration/transparency_report_test.rb` |
| **Publish** the report (at least annually). | The gem produces the numbers; **you** publish them (a `/transparency` page, a PDF, whatever) — only you know your reporting period and format. | **[you]** | manual: render `Moderate.transparency(from:, to:)` |
> [!TIP]
@@ -111,7 +111,7 @@ Apple is blunt: an app with UGC that lacks these gets **rejected**, and rejectio
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
| **(a)** A method to **filter objectionable material** before it's posted. | `moderates :field` with `mode: :block` rejects the offending write before save; the default `:wordlist` adapter is a fast offline baseline, and you can register an image / remote adapter for stronger checks. | **[gem]** | `test/models/filtering_block_mode_test.rb` |
-| **(b)** A mechanism to **report** offensive content. | `current_user.report!(content, category:)` in-app; `reportable` content exposes `reports`, `reported?`, `flagged?`; the `moderate_report_link` helper drops the button into any view. | **[gem]** | `test/models/reportable_test.rb`, `test/helpers/report_link_test.rb` |
+| **(b)** A mechanism to **report** offensive content. | `current_user.report!(content, category:)` in-app; reportable content exposes `reports`, `reported?`, `flagged?`; the `moderate_report_link` helper drops the button into any view. | **[gem]** | `test/models/reportable_test.rb`, `test/helpers/report_link_test.rb` |
| **(b)** **Timely responses** to reports. | The report lands in `Moderate::Report.pending` with a snapshot; the reporter gets a `report_received` receipt immediately, and a `report_decision` when you act. (Acting promptly is on you — the gem surfaces the queue and the events.) | **[gem + you]** | `test/services/report_received_event_test.rb` |
| **(c)** The ability to **block abusive users**. | `current_user.block!(other)` — bidirectional, idempotent, audited; enforce it everywhere with the single `Moderate.blocked_ids_for(user)` query. | **[gem]** | `test/models/block_test.rb` |
| **(d)** **Published contact information** to reach the developer. | The notice-engine root (`/legal`) is a natural home for your contact/abuse address; the gem gives you the page, **you** publish the address (Apple wants a real human-reachable contact). | **[gem + you]** | manual: contact shown in-app + on the notice page |
@@ -132,7 +132,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is `reportable` can be reported. | **[gem]** | `test/models/reportable_test.rb` |
+| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is reportable can be reported. | **[gem]** | `test/models/reportable_test.rb` |
| In-app **reporting** of objectionable **users**. | A user model with `has_reporting_and_blocking` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/block_test.rb` |
| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/models/blocked_ids_scope_test.rb` |
@@ -141,7 +141,7 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Users **accept terms / acceptable-use** before contributing UGC. | This is your signup/terms gate — `moderate` doesn't own it — but, as with Apple, your acceptable-use policy should enumerate the **community-report categories** so the terms and the report buttons describe the same prohibited behavior. | **[you]** | manual: terms acceptance in your onboarding |
> [!NOTE]
-> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_reporting_and_blocking` is *also* `reportable`, and blocking is enforced over content via `blocked_ids_for`. If you only made content `reportable` and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
+> **"Both users and content" is the row people miss.** Plenty of apps add a "Report comment" button and stop there. Play wants you to be able to report **and** block **both** a person and a thing. `moderate` covers all four cells because a user model with `has_reporting_and_blocking` is *also* reportable, and blocking is enforced over content via `blocked_ids_for`. If you only made content reportable and never made users blockable, you'd pass Apple's spot check and still fail Play's policy.
---
@@ -159,7 +159,7 @@ If you read nothing else, this is the table that says "we did the thing."
| **Apple 1.2(b)** | Report + timely response | `report!` + `Report.pending` + `report_decision` | ✅ gem (you respond) |
| **Apple 1.2(c)** | Block abusive users | `block!` + `blocked_ids_for` | ✅ gem |
| **Apple 1.2(d)** | Published contact | `/legal` page | ✅ gem (you publish address) |
-| **Play UGC** | Report + block, **users and content** | `report!`/`block!` on users; `reportable` + `blocked_ids_for` on content | ✅ gem |
+| **Play UGC** | Report + block, **users and content** | `report!`/`block!` on users; reportable + `blocked_ids_for` on content | ✅ gem |
| **Play UGC** | Ongoing moderation surface | `Flag.pending` + `:flag`-mode filtering | ✅ gem (you review) |
| **Play UGC** | Accept terms before UGC | your onboarding gate | ⬜ you (categories align) |
diff --git a/lib/moderate.rb b/lib/moderate.rb
index fb5fe10..04fb866 100644
--- a/lib/moderate.rb
+++ b/lib/moderate.rb
@@ -300,8 +300,41 @@ def locale
config.locale || (defined?(I18n) ? I18n.default_locale : :en)
end
+ # DSA Art. 24 transparency aggregation for a period — the numbers a host
+ # publishes (notices received by intake/ground, actions taken, automated-means
+ # usage, appeal outcomes, median handling times). This is the queryable building
+ # block: the public `/transparency` page (off by default — see
+ # `config.transparency_report_enabled`) renders this, and a host that keeps the
+ # page off can still call this to publish its own report in its own format.
+ def transparency(from: nil, to: nil)
+ to ||= Time.respond_to?(:current) ? Time.current : Time.now
+ from ||= to - (365 * 24 * 60 * 60)
+ reports = Moderate::Report.where(created_at: from..to)
+ appeals = Moderate::Appeal.where(created_at: from..to)
+ flags = Moderate::Flag.where(created_at: from..to)
+
+ {
+ period: { from: from, to: to },
+ notices_by_intake: reports.group(:intake_kind).count,
+ dsa_notices_by_legal_reason: reports.where(intake_kind: "dsa").group(:legal_reason).count,
+ actions_by_basis: reports.where.not(resolved_at: nil).group(:resolution_basis).count,
+ automated_flags_by_source: flags.group(:source).count,
+ appeals_by_status: appeals.group(:status).count,
+ median_notice_action_seconds: transparency_median(reports.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)),
+ median_appeal_action_seconds: transparency_median(appeals.where.not(resolved_at: nil).pluck(:created_at, :resolved_at))
+ }
+ end
+
private
+ # Median seconds between paired (created_at, resolved_at) timestamps; 0 when empty.
+ def transparency_median(pairs)
+ values = pairs.filter_map { |created_at, resolved_at| resolved_at && created_at ? (resolved_at - created_at).to_i : nil }.sort
+ return 0 if values.empty?
+
+ values[values.length / 2]
+ end
+
# The internal reportable-name set. Set (not Array) so re-including the concern
# is idempotent.
def reportable_registry
diff --git a/lib/moderate/models/appeal.rb b/lib/moderate/models/appeal.rb
index 75b124b..f1ef77c 100644
--- a/lib/moderate/models/appeal.rb
+++ b/lib/moderate/models/appeal.rb
@@ -81,6 +81,19 @@ def rejected?
status == "rejected"
end
+ # Decide this appeal — model-level sugar over Moderate::Services::ResolveAppeal,
+ # so a host can write `appeal.uphold!(by: moderator, note: "…")` /
+ # `appeal.reject!(by: moderator, note: "…")` instead of constructing the service.
+ # The service does the real work (audit, the appeal-decision notification, and —
+ # for an upheld appeal — reversing the original decision); these only forward.
+ def uphold!(by:, note:)
+ Moderate::Services::ResolveAppeal.new(self, by: by).uphold!(note: note)
+ end
+
+ def reject!(by:, note:)
+ Moderate::Services::ResolveAppeal.new(self, by: by).reject!(note: note)
+ end
+
private
# See the before_save comment: keep the NOT-NULL JSON `snapshot` non-null on MySQL.
diff --git a/test/integration/transparency_report_test.rb b/test/integration/transparency_report_test.rb
index 0f975ab..331ce8d 100644
--- a/test/integration/transparency_report_test.rb
+++ b/test/integration/transparency_report_test.rb
@@ -26,4 +26,28 @@ class TransparencyReportTest < ActionDispatch::IntegrationTest
assert_includes response.body, "Moderation transparency"
assert_includes response.body, "Public security"
end
+
+ test "Moderate.transparency aggregates the period counters and is queryable even when the page is OFF" do
+ Moderate.config.transparency_report_enabled = false # the page is disabled…
+
+ Moderate::Report.create!(
+ notifier_name: "Notice Sender",
+ notifier_email: "notice@example.com",
+ category: "illegal_content",
+ intake_kind: "dsa",
+ legal_reason: "public_security",
+ legal_country_code: "ES",
+ content_type: "listing",
+ subject_url: "https://example.test/content/2",
+ message: "Please review",
+ good_faith_confirmed: true
+ )
+
+ # …but the aggregation a host needs to publish its own report still works.
+ summary = Moderate.transparency(from: 1.day.ago, to: Time.current)
+ assert_equal({ "dsa" => 1 }, summary[:notices_by_intake])
+ assert_equal({ "public_security" => 1 }, summary[:dsa_notices_by_legal_reason])
+ assert summary.key?(:median_notice_action_seconds)
+ assert summary.key?(:appeals_by_status)
+ end
end
diff --git a/test/services/moderate/resolve_appeal_test.rb b/test/services/moderate/resolve_appeal_test.rb
index c21f420..20754c2 100644
--- a/test/services/moderate/resolve_appeal_test.rb
+++ b/test/services/moderate/resolve_appeal_test.rb
@@ -25,6 +25,19 @@ class ResolveAppealTest < ActiveSupport::TestCase
ModerateTestRecorder.clear
end
+ test "Appeal#uphold! and #reject! delegate to the service (the README's plain-English API)" do
+ moderator = User.create!(name: "Mod")
+
+ appeal = create_appeal
+ appeal.uphold!(by: moderator, note: "We reversed the decision.")
+ assert_equal "upheld", appeal.reload.status
+ assert_equal moderator, appeal.resolved_by
+
+ other = create_appeal
+ other.reject!(by: moderator, note: "Original decision stands.")
+ assert_equal "rejected", other.reload.status
+ end
+
test "upholding closes the appeal, stamps the human decider, audits, and notifies" do
moderator = User.create!(name: "Mod")
appeal = create_appeal
From 6afb7507b1056cf5f61f6a56b56e36f11d3bf6c0 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Wed, 3 Jun 2026 00:51:31 +0100
Subject: [PATCH 14/15] docs(README): production-realistic filtering examples
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make the two filtering examples distinct and true-to-API: the teaser now shows the
production case (OpenAI moderation, :flag, async→queue, with the one-line
register_adapter), and Quickstart shows the zero-setup case (built-in :wordlist,
:block). Different model/mode/adapter in each.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 22 ++++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 9f61ee0..4ac5e46 100644
--- a/README.md
+++ b/README.md
@@ -30,14 +30,24 @@ current_user.block!(@other_user)
current_user.blocks?(@other_user) # => true
```
-Filter content before it's ever saved with just one line:
+Filter content before it's posted — the zero-setup wordlist, or a real classifier like OpenAI moderation:
+
+```ruby
+# config/initializers/moderate.rb — wire up OpenAI moderation once (text AND images)
+config.register_adapter :openai, OpenAIModerationAdapter.new
+```
```ruby
class Message < ApplicationRecord
- moderates :body # blocks profanity/slurs/threats by default; or :flag for review
+ # 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:
```ruby
@@ -79,12 +89,12 @@ end
```ruby
class User < ApplicationRecord
- has_reporting_and_blocking # can report, block, be blocked, be banned
+ has_reporting_and_blocking # can report, block, be blocked, be banned
end
-class Message < ApplicationRecord
- has_reportable_content # can be reported
- moderates :body # …and filtered before it's saved
+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
```
From 6ef04649586ce92bb945ebc7c1c191ff04a48158 Mon Sep 17 00:00:00 2001
From: Javi R <4920956+rameerez@users.noreply.github.com>
Date: Wed, 3 Jun 2026 00:53:47 +0100
Subject: [PATCH 15/15] docs(compliance): repoint test-file citations at real
files
Every [gem] evidence citation in docs/compliance.md now resolves to a real test
(the doc had ~20 aspirational/namespace-stale paths). Each was repointed to the
test that actually exercises that guarantee (verified): decision guarantees ->
resolve_report_test, appeal guarantees -> resolve_appeal_test, snapshot/user-
reportable -> report_test, blocked_ids -> blocking_test, filter modes ->
content_filtering_test, report-link/report! -> reporting_test, etc.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/compliance.md | 42 +++++++++++++++++++++---------------------
moderate.gemspec | 2 +-
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/docs/compliance.md b/docs/compliance.md
index cc423ef..8fd78ea 100644
--- a/docs/compliance.md
+++ b/docs/compliance.md
@@ -61,12 +61,12 @@ The statement must include, at minimum: the **restriction imposed** (and its sco
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| State the **specific restriction** imposed and its scope (content removed? account suspended?). | Every resolution records its action (`remove_content:`, `ban_user:`) and emits `affected_user_decision` with that action in `event.payload`. | **[gem]** | `test/services/resolve_records_action_test.rb` |
-| State the **facts and circumstances** relied on. | The immutable **evidence snapshot** taken at report time travels with the decision; the moderator's mandatory `note:` is the human-readable ground. | **[gem]** | `test/models/evidence_snapshot_test.rb` |
-| State whether **automated means** were used in detection or decision — Art. 17(3)(c). | Reports/flags carry their `source` (`text_filter`, `image_filter`, `external_classifier`, or `manual`). A decision acting on an auto-`Moderate::Flag` is flagged as automated-means in the `affected_user_decision` payload; a human report is not. | **[gem]** | `test/services/automated_means_flag_test.rb` |
-| State the **legal or contractual ground**. | For DSA notices, the `legal_reason` (from `Moderate::DSA_LEGAL_REASONS`) is the legal ground; for in-app reports, the community `category` + your `note:` is the contractual (terms-of-service) ground. Both ride in the decision payload. | **[gem + you]** | `test/services/decision_ground_test.rb` |
-| Communicate **redress** options (internal complaint, out-of-court, judicial). | The `affected_user_decision` event carries the appeal entry point; you render the redress text in your decision email (the gem ships the data; the copy is yours, because it names your jurisdiction). | **[gem + you]** | `test/services/decision_includes_redress_test.rb` |
-| Deliver the statement to the **affected recipient** (the content owner), not just the reporter. | Two distinct events fire: `report_decision` → the **reporter**; `affected_user_decision` → the **content owner** (resolved via the reportable's `reported_owner`). You wire both. | **[gem + you]** | `test/services/decision_recipients_test.rb` |
+| State the **specific restriction** imposed and its scope (content removed? account suspended?). | Every resolution records its action (`remove_content:`, `ban_user:`) and emits `affected_user_decision` with that action in `event.payload`. | **[gem]** | `test/services/moderate/resolve_report_test.rb` |
+| State the **facts and circumstances** relied on. | The immutable **evidence snapshot** taken at report time travels with the decision; the moderator's mandatory `note:` is the human-readable ground. | **[gem]** | `test/models/moderate/report_test.rb` |
+| State whether **automated means** were used in detection or decision — Art. 17(3)(c). | Reports/flags carry their `source` (`text_filter`, `image_filter`, `external_classifier`, or `manual`). A decision acting on an auto-`Moderate::Flag` is flagged as automated-means in the `affected_user_decision` payload; a human report is not. | **[gem]** | `test/services/moderate/resolve_report_test.rb` |
+| State the **legal or contractual ground**. | For DSA notices, the `legal_reason` (from `Moderate::DSA_LEGAL_REASONS`) is the legal ground; for in-app reports, the community `category` + your `note:` is the contractual (terms-of-service) ground. Both ride in the decision payload. | **[gem + you]** | `test/services/moderate/resolve_report_test.rb` |
+| Communicate **redress** options (internal complaint, out-of-court, judicial). | The `affected_user_decision` event carries the appeal entry point; you render the redress text in your decision email (the gem ships the data; the copy is yours, because it names your jurisdiction). | **[gem + you]** | `test/services/moderate/resolve_report_test.rb` |
+| Deliver the statement to the **affected recipient** (the content owner), not just the reporter. | Two distinct events fire: `report_decision` → the **reporter**; `affected_user_decision` → the **content owner** (resolved via the reportable's `reported_owner`). You wire both. | **[gem + you]** | `test/services/moderate/resolve_report_test.rb` |
> [!NOTE]
> **Why two decision events.** Art. 16(5) wants the **notifier** informed; Art. 17 wants the **affected user** informed — they are different people with different rights. `moderate` keeps them separate (`report_decision` vs `affected_user_decision`) so your one `notify` hook sends the right message to the right person. Collapsing them into one email is a classic DSA mistake. See [Notifications](../README.md#-notifications---audit--one-hook-each).
@@ -77,12 +77,12 @@ The statement must include, at minimum: the **restriction imposed** (and its sco
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| An **internal** appeal mechanism against moderation decisions. | `Moderate::Appeal` — a complaint filed against a resolved report/notice; the queue is `Moderate::Appeal.pending`. | **[gem]** | `test/models/appeal_test.rb` |
+| An **internal** appeal mechanism against moderation decisions. | `Moderate::Appeal` — a complaint filed against a resolved report/notice; the queue is `Moderate::Appeal.pending`. | **[gem]** | `test/models/moderate/appeal_test.rb` |
| **Free of charge.** | There is no charge anywhere in the appeal path — it's just a record + a queue. (You simply don't bill for it.) | **[gem]** | n/a (no payment code exists in the path) |
-| Open for **at least six months** after the decision. | Each report stores its **appeal window**; the gem's default window is **6 months** and `Moderate::Appeal` refuses to open against a decision whose window has closed. | **[gem]** | `test/models/appeal_window_test.rb` |
-| Decisions **reversible** — uphold the complaint and reverse the action. | `appeal.uphold!(by:, note:)` overturns the original decision (and runs the reverse enforcement); `appeal.reject!(by:, note:)` confirms it. | **[gem]** | `test/services/appeal_uphold_test.rb` |
-| **Not solely automated** — a human decides the complaint. | `uphold!`/`reject!` **require** a `by:` moderator and a `note:`; there is no path to auto-decide an appeal. | **[gem]** | `test/services/appeal_requires_human_test.rb` |
-| Inform the complainant of the **appeal decision** and remaining redress (out-of-court / judicial). | Resolving an appeal emits `appeal_decision` to the complainant; you render the out-of-court / judicial redress copy. | **[gem + you]** | `test/services/appeal_decision_event_test.rb` |
+| Open for **at least six months** after the decision. | Each report stores its **appeal window**; the gem's default window is **6 months** and `Moderate::Appeal` refuses to open against a decision whose window has closed. | **[gem]** | `test/models/moderate/appeal_test.rb` |
+| Decisions **reversible** — uphold the complaint and reverse the action. | `appeal.uphold!(by:, note:)` overturns the original decision (and runs the reverse enforcement); `appeal.reject!(by:, note:)` confirms it. | **[gem]** | `test/services/moderate/resolve_appeal_test.rb` |
+| **Not solely automated** — a human decides the complaint. | `uphold!`/`reject!` **require** a `by:` moderator and a `note:`; there is no path to auto-decide an appeal. | **[gem]** | `test/services/moderate/resolve_appeal_test.rb` |
+| Inform the complainant of the **appeal decision** and remaining redress (out-of-court / judicial). | Resolving an appeal emits `appeal_decision` to the complainant; you render the out-of-court / judicial redress copy. | **[gem + you]** | `test/services/moderate/resolve_appeal_test.rb` |
### Art. 24 — Transparency reporting
@@ -110,10 +110,10 @@ Apple is blunt: an app with UGC that lacks these gets **rejected**, and rejectio
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| **(a)** A method to **filter objectionable material** before it's posted. | `moderates :field` with `mode: :block` rejects the offending write before save; the default `:wordlist` adapter is a fast offline baseline, and you can register an image / remote adapter for stronger checks. | **[gem]** | `test/models/filtering_block_mode_test.rb` |
-| **(b)** A mechanism to **report** offensive content. | `current_user.report!(content, category:)` in-app; reportable content exposes `reports`, `reported?`, `flagged?`; the `moderate_report_link` helper drops the button into any view. | **[gem]** | `test/models/reportable_test.rb`, `test/helpers/report_link_test.rb` |
-| **(b)** **Timely responses** to reports. | The report lands in `Moderate::Report.pending` with a snapshot; the reporter gets a `report_received` receipt immediately, and a `report_decision` when you act. (Acting promptly is on you — the gem surfaces the queue and the events.) | **[gem + you]** | `test/services/report_received_event_test.rb` |
-| **(c)** The ability to **block abusive users**. | `current_user.block!(other)` — bidirectional, idempotent, audited; enforce it everywhere with the single `Moderate.blocked_ids_for(user)` query. | **[gem]** | `test/models/block_test.rb` |
+| **(a)** A method to **filter objectionable material** before it's posted. | `moderates :field` with `mode: :block` rejects the offending write before save; the default `:wordlist` adapter is a fast offline baseline, and you can register an image / remote adapter for stronger checks. | **[gem]** | `test/integration/content_filtering_test.rb` |
+| **(b)** A mechanism to **report** offensive content. | `current_user.report!(content, category:)` in-app; reportable content exposes `reports`, `reported?`, `flagged?`; the `moderate_report_link` helper drops the button into any view. | **[gem]** | `test/models/moderate/reportable_test.rb`, `test/integration/reporting_test.rb` |
+| **(b)** **Timely responses** to reports. | The report lands in `Moderate::Report.pending` with a snapshot; the reporter gets a `report_received` receipt immediately, and a `report_decision` when you act. (Acting promptly is on you — the gem surfaces the queue and the events.) | **[gem + you]** | `test/services/moderate/intake_report_test.rb` |
+| **(c)** The ability to **block abusive users**. | `current_user.block!(other)` — bidirectional, idempotent, audited; enforce it everywhere with the single `Moderate.blocked_ids_for(user)` query. | **[gem]** | `test/models/moderate/block_test.rb` |
| **(d)** **Published contact information** to reach the developer. | The notice-engine root (`/legal`) is a natural home for your contact/abuse address; the gem gives you the page, **you** publish the address (Apple wants a real human-reachable contact). | **[gem + you]** | manual: contact shown in-app + on the notice page |
> [!IMPORTANT]
@@ -132,12 +132,12 @@ Google Play's UGC policy overlaps heavily with Apple's but is explicit about **t
| Requirement | How `moderate` satisfies it | Who | Proof |
| --- | --- | --- | --- |
-| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is reportable can be reported. | **[gem]** | `test/models/reportable_test.rb` |
-| In-app **reporting** of objectionable **users**. | A user model with `has_reporting_and_blocking` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/report_user_test.rb` |
-| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/block_test.rb` |
-| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/models/blocked_ids_scope_test.rb` |
-| A method to **moderate UGC** (a real review surface, not just intake). | `Moderate::Report.pending` / `Moderate::Flag.pending` give admins the queue; `resolve!`/`dismiss!`/`remove_content`/`ban_user` are the audited actions. (BYOUI — you bind these to your admin; see [`docs/madmin.md`](madmin.md).) | **[gem + you]** | `test/services/resolve_test.rb` |
-| **Ongoing** moderation, including proactive detection. | Pre-publication filtering (`moderates`) catches content at write time; `:flag` mode queues borderline content for review **after commit**; both feed the same admin queue. The mechanism is continuous, not one-shot. | **[gem]** | `test/models/filtering_flag_mode_test.rb` |
+| In-app **reporting** of objectionable **content**. | `current_user.report!(content, category:)`; any model that is reportable can be reported. | **[gem]** | `test/models/moderate/reportable_test.rb` |
+| In-app **reporting** of objectionable **users**. | A user model with `has_reporting_and_blocking` is itself reportable: `current_user.report!(other_user, category: :impersonation)`. | **[gem]** | `test/models/moderate/report_test.rb` |
+| In-app **blocking** of objectionable **users**. | `current_user.block!(other)` — the bidirectional safety edge. | **[gem]** | `test/models/moderate/block_test.rb` |
+| In-app **blocking / hiding** of objectionable **content**. | Filter the blocked pair's content out of any feed with `Moderate.blocked_ids_for(current_user)` — the single source-of-truth query you apply in search, inbox, and listings. | **[gem]** | `test/integration/blocking_test.rb` |
+| A method to **moderate UGC** (a real review surface, not just intake). | `Moderate::Report.pending` / `Moderate::Flag.pending` give admins the queue; `resolve!`/`dismiss!`/`remove_content`/`ban_user` are the audited actions. (BYOUI — you bind these to your admin; see [`docs/madmin.md`](madmin.md).) | **[gem + you]** | `test/services/moderate/resolve_report_test.rb` |
+| **Ongoing** moderation, including proactive detection. | Pre-publication filtering (`moderates`) catches content at write time; `:flag` mode queues borderline content for review **after commit**; both feed the same admin queue. The mechanism is continuous, not one-shot. | **[gem]** | `test/integration/content_filtering_test.rb` |
| Users **accept terms / acceptable-use** before contributing UGC. | This is your signup/terms gate — `moderate` doesn't own it — but, as with Apple, your acceptable-use policy should enumerate the **community-report categories** so the terms and the report buttons describe the same prohibited behavior. | **[you]** | manual: terms acceptance in your onboarding |
> [!NOTE]
diff --git a/moderate.gemspec b/moderate.gemspec
index 94013b9..462e519 100644
--- a/moderate.gemspec
+++ b/moderate.gemspec
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
spec.email = ["rubygems@rameerez.com"]
spec.summary = "Let your Rails users report content and block each other (Trust & Safety)"
- spec.description = "moderate is a complete Trust & Safety and content moderation engine for Ruby on Rails apps. 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, 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). Any app with user-generated content (UGC) needs this: social apps, marketplaces, dating, communities, forums, comments, reviews, chat, etc. The moderate gem bundles the four things every UGC app needs behind one data model and one set of hooks: abuse reporting (report posts, comments, profiles, listings, messages, and other users), bidirectional user blocking behind a single enforced source of truth, pre-publication content filtering for profanity, slurs, hate, spam, harassment, and objectionable or NSFW text and images in off/block/flag modes, and an audited moderation queue with locked resolve/dismiss/remove-content/ban decisions, internal appeals, and statement-of-reasons notifications. Filtering uses a tiny classify(value) => Result adapter contract: a fast, offline, multilingual wordlist/profanity/bad-word blocklist ships as the zero-dependency default, and you bring your own classifier (OpenAI omni-moderation, AWS Rekognition image/NSFW detection, Google Perspective, or self-hosted) as an optional reference adapter, not a forced dependency. moderate also ships primitives aligned with the EU Digital Services Act (DSA notice-and-action, statement of reasons, appeals, transparency) and with the Apple App Store (Guideline 1.2) and Google Play user-generated-content review rules that get apps rejected without report/block/filter. It is a mountable, UI-agnostic Rails engine with no hard dependencies beyond normal Rails stuff."
+ spec.description = "moderate is a complete Trust & Safety system and content moderation engine for Ruby on Rails apps. 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). Any app with user-generated content (UGC) needs this: social apps, marketplaces, dating, communities, forums, comments, reviews, chat, etc. The moderate gem bundles the four things every UGC app needs behind one data model and one set of hooks: abuse reporting (report posts, comments, profiles, listings, messages, and other users), bidirectional user blocking behind a single enforced source of truth, pre-publication content filtering for profanity, slurs, hate, spam, harassment, and objectionable or NSFW text and images in off/block/flag modes, and an audited moderation queue with locked resolve/dismiss/remove-content/ban decisions, internal appeals, and statement-of-reasons notifications. Filtering uses a tiny classify(value) => Result adapter contract: a fast, offline, multilingual wordlist/profanity/bad-word blocklist ships as the zero-dependency default, and you bring your own classifier (OpenAI omni-moderation, AWS Rekognition image/NSFW detection, Google Perspective, or self-hosted) as an optional reference adapter, not a forced dependency. moderate also ships primitives aligned with the EU Digital Services Act (DSA notice-and-action, statement of reasons, appeals, transparency) and with the Apple App Store (Guideline 1.2) and Google Play user-generated-content review rules that get apps rejected without report/block/filter. It is a mountable, UI-agnostic Rails engine with no hard dependencies beyond normal Rails stuff."
spec.homepage = "https://github.com/rameerez/moderate"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"