Repository navigation
1.0.0 - #11
Merged
Merged
1.0.0#11
Conversation
The gemspec requires Ruby 3.3+ and activerecord/activesupport/railties >= 7.2.3.1, < 9.0. The Rails 7.1 appraisal goes, an 8.0 one arrives, and CI runs Ruby 3.3/3.4/4.0 against Rails 7.2, 8.0 and 8.1 on SQLite (plus PostgreSQL and MySQL). The json < 3 pin moves from the 7.1 appraisal to the 8.0 one: Rails 8.0 still passes quirks_mode: to JSON.generate. The dummy app loads 7.2 defaults, its migrations are [7.2], and the pre-7.1 fixture_path fallback in the test helper is gone. Appraisal lockfiles are ignored (CI resolves them fresh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Active Storage uploads a new attachment in its own after_commit, and commit callbacks run in definition order, so a model that calls `moderates` above `has_one_attached` classified (or enqueued ClassifyJob for) a blob whose file wasn't on the service yet. The filter now hands a field whose upload is still pending to that upload (a per-object hook on the Active Storage change) and runs the moment it completes; in the other order it runs at once. ClassifyJob also waits for a blob that isn't on its service, re-enqueuing itself up to UPLOAD_ATTEMPTS times, then audits classification_skipped. The CHANGELOG says how a host that worked around this (CarHey's avatar) removes its override and its own enqueue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Appeal#report_must_still_be_appealable ran on every save, so uphold! or
reject! on a complaint filed inside the six-month window failed once the
window had passed ("the appeal window for this decision has closed").
DSA Art. 20 bounds when a complaint may be filed, not when it is
decided: the check now runs on create only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moderate.purge(older_than: 1.year, dry_run: false) and the moderate:purge rake task redact the personal data in reports, notices, flags and appeals whose decision is final: closed, decided more than older_than ago, out of the appeal window, with no open appeal. Every row and every decision stays, so statements of reasons, repeat-misuse history and Moderate.transparency are unchanged. Blocks and pending records are never touched, and older_than below the six-month appeal window raises. Idempotent, batched, audited as :retention_purged. docs/compliance.md gains a retention section: what each table stores, that nothing is purged by default, and the DSA and GDPR expectations in plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every supported Rails has Model.lease_connection; the respond_to? branch to Model.connection existed only for Rails 7.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
config.signed_gid_purposes was set, generated into the initializer and documented, and read by nothing: each token's purpose is fixed by the flow that mints it. It goes before 1.0 freezes the configuration keys. docs/notifications.md's statement-of-reasons mailer read event.payload[:appeal_url], which the gem never sets, so a copied mailer had an appeal button to nowhere. It now builds the link from report.signed_appeal_gid, as CarHey's working mailer does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's "What is public" section names everything that keeps its name, arguments and meaning until 2.0: macros, report!/block!, the reportable contract, decision methods and services, flag_for_review!, taxonomy_label, reportable_field_label, blocked_ids_for, purge, every configuration key, view helpers, the controller concern, the adapter contract, and the event vocabulary with the notifier contract. The vocabulary is code now: Moderate::Event::NOTIFY_NAMES / AUDIT_NAMES. test/public_api_test.rb reads the section and fails when a listed name doesn't exist, when a public Moderate.* method or config key is unlisted (and not tagged @api private), when the event lists drift from what lib/ and app/ emit, or when the README or docs/ name a Moderate constant or method that doesn't exist. The helpers hosts were reaching for are tagged @api private (nothing removed), the engine's path constants are private_constant, and the docs that named Moderate::DSA_LEGAL_REASONS, or said audit fires for every event, are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever existed The header promised Flag.flag! was an upsert that deduped retried jobs; flag! always creates (it is the job's last step, so a retry after a failed adapter call still files one flag). The queue block probed config.job_queue, which was never a configuration key; it is plain queue_as :default now, overridable with ClassifyJob.queue_as. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.rubocop.yml now describes the code as it is: size metrics off (the gem explains itself in long, commented methods), complexity metrics capped at today's shape, 140-column lines, continuation lines indented one step, the ecosystem's has_* macro names allowed, bang methods allowed to answer true/false, and class_methods treated as its own scope. New cops are enabled. Everything else is fixed: safe autocorrections, plus by hand the documentation comments on the small classes, FileUtils.rm_f, a memoized ivar named after its method, and a before_save that no longer pretends a `true` keeps the save going. resolve_flag.rb and resolve_report.rb are not edited (they change on fix/mysql-race); each has one aligned continuation, excluded from its cop until the beta4 rebase. A `rubocop` job joins the test workflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README says `validates :body, moderate: true` still works. It raised "Unknown validator: 'ModerateValidator'": the file was Zeitwerk-ignored and never required, and it called a 0.x word list that downloads over HTTP and a configuration key 1.0 doesn't have. The engine now requires it, and it is the README's promise: the built-in :wordlist in :block mode, adding :objectionable_content (or message:). The unreachable 0.x Moderate::Text and Moderate::WordList are deleted. A :block rejection added :objectionable_content with no translation, so people read "Body Translation missing...". The gem ships errors.messages.objectionable_content in English and Spanish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rails 7.2's security support ended in August 2026, so it goes the way of 7.1: the gemspec requires activerecord/activesupport/railties >= 8.0.5.1, < 9.0 (the first 8.0 release with every current advisory fixed; 8.0.4.1 and 8.0.5 still carry the Active Storage one). The 7.2 appraisal is deleted, 8.0 (with its json < 3 pin) and 8.1 stay, the dummy loads 8.0 defaults and its migrations are [8.0]. A bundler-audit job checks Gemfile.lock and both appraisal locks, resolving each appraisal lock first since they aren't committed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilently Codex review, findings 2 and 3. The synchronous path classified an attached blob or signed id at once (Active Storage's upload is a no-op for those) even if the client hadn't finished uploading; and an upload that raised in Active Storage's after_commit left a committed attachment with nothing queued and nothing audited. The filter now watches the upload from before_save, in either declaration order. Just before it starts, a ClassifyJob is queued UPLOAD_WAIT seconds out: the durable intent, which waits (bounded) and classifies or audits classification_skipped. Once the upload returns, the field is classified at once if every file is on the service (inline for a synchronous adapter, an immediate job for an async one), else left to that job. The job takes blob_ids: and stops if the field holds other files by then; whoever classifies first marks the blobs (Moderate::Attachments, in blob metadata) and the rest stop. Tests: a direct upload in flight, an upload that raises, a replaced attachment, and the guard finding an inline classification done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
db/migrate/20261018210000_add_retention_columns_to_moderate_tables.rb adds redacted_at to reports, flags and appeals and held_until (indexed) to reports, for the retention fixes that follow. It ships with the gem and runs with bin/rails db:migrate, guarded both ways (a fresh install from the generator already has the columns), and sorts after beta4's pending_key migration. The install template gets the same columns. The engine's migration-path initializer is beta4's, verbatim: it adds the gem's db/migrate to the host's paths without the duplicate that the gem's own Rakefile or a host's moderate:install:migrations copies would cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex review, findings 4 and 5.
The purge recognised a done row by its content ("[redacted]"), so a
message, excerpt or reason that literally read that kept its snapshot,
URLs, contacts and reporter, and dry_run under-counted it. Rows are now
stamped redacted_at and the purge selects on that column only.
The one-year default could erase evidence while an out-of-court (DSA
Art. 21) or court dispute was running. Moderate.hold(report, until:,
by:, reason:) / Moderate.release_hold and Report#held? keep a report,
its appeals and the flags on its content out of the purge until the
date; purge and dry_run report held rows under :held, the rake task
prints them, and both are audited (:legal_hold_placed,
:legal_hold_released). docs/compliance.md qualifies 1.year as a default
the host sets for its own purposes, and documents the hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex review, finding 6. appeal_deadline_at was decision time + six months, so a statement of reasons delivered late (a decision held with deliver: false, a retried mailer) shortened the window the law gives. A delivered decision notice already stamps decision_notified_at or affected_user_notified_at; Report#record_notified! now stamps it and moves appeal_deadline_at to six months after delivery, never earlier, and the notice's appeal_deadline_at payload states that date. With no notifier wired, the decision-time deadline stands. No new column. resolve_report.rb changes in three lines (the two stamps and the payload), in hunks beta4 doesn't touch. Tests: a notice two months late, an appeal seven months after the decision (in time) and nine (late), no notifier, never earlier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex confirming pass, finding 3. Moderate.notify treats any truthy
return as delivered, so a queued delivery job started the extended
appeal window, and re-running deliver_decision_notices restamped and
extended it.
Moderate.delivered!(report, :decision | :affected_user, at:) is what a
notifier calls when the message actually went out (Action Mailer's
after_deliver, shown in docs/notifications.md). Under the report's lock
it stamps decision_notified_at / affected_user_notified_at once and
moves appeal_deadline_at to six months after it, never earlier; later
calls change nothing. The decision services no longer stamp either
column, and the notice's appeal_deadline_at is the decision-time
minimum ("at least until").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; :block waits for the upload
Codex confirming pass, findings 1 and 2.
The blob marker wasn't atomic: the immediate job and the 5 s guard
could both pass the check while one classified, and both file flags.
The inline path and ClassifyJob now check, classify, flag and mark
under the content record's row lock (content first, as everywhere).
The marker key adds a digest of the ordered blob ids, so a changed set
(a file removed from a has_many) or the same blob on another record is
classified again; the write re-reads and merges the blob's metadata,
keeping the analyzer's keys. The analyzer's own unlocked write can
still drop the mark in a rare race; the set is then classified once
more, which beta4's unique pending flag keeps from filing twice.
:block validated an attachment by classifying it even when its stored
blob wasn't on the service (a direct upload in flight). It now fails
with errors.messages.upload_not_finished ("hasn't finished uploading
yet", en/es): nothing enters unverified.
Tests: two jobs at once call the adapter once (PostgreSQL/MySQL; it
fails without the lock), a changed set, a shared blob, and :block with
a direct upload in flight then finished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator's decision on the lock: holding the content row's lock
for the adapter call let a slow provider block every write to that row
(a user's, for an avatar). Moderate::Attachments.classify_once now:
1. under the content lock, stops if these files are classified or a
live claim by someone else exists, else writes a claim {owner,
claimed_at} beside the marker (blob metadata, keyed by record +
field + set digest) and releases the lock;
2. calls the adapter outside any lock;
3. under the lock again, files the flag, marks the files classified
and clears the claim only if the field still holds the same files
and the claim is still ours; else discards the result.
A claim expires after config.classification_claim_ttl (10 minutes); a
job that finds a live claim looks once more at its expiry, so a worker
that died mid-call is taken over.
Tests: two concurrent jobs make one adapter call; another connection
updates the content row while the adapter runs (it times out if the
call is made under the lock); a stale claim is taken over; a live one
is rechecked once at expiry; files changed mid-call discard the result
and the new files are classified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etadata Codex's second confirming pass. Blob metadata is shared by every record attached to that blob and rewritten whole by Active Storage's analyzer, so another record or the analyzer could overwrite a claim; the worker whose claim vanished then discarded its result and scheduled no retry. Claims and completions now live in the gem's own table, moderate_classifications (record, field, a digest of the ordered blob ids, claimed_by / claimed_at, classified_at, adapter, flagged; UNIQUE on record + field + digest), written only under the content record's lock: insert, or take over when expired; completion stamps classified_at and clears the claim. Nothing of ours is written to blob.metadata any more. The table joins the 1.0 migration (renamed add_retention_and_classifications_to_moderate_tables) and the install template; Moderate.purge deletes rows last touched before its cutoff, and docs/compliance.md lists the table (ids, a digest, timestamps). Tests: two records sharing a blob get one row and one adapter call each; the analyzer rewriting metadata mid-call changes nothing; the claim tests read and write the table; old rows are purged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's third pass: Moderate.purge chose classification rows by updated_at alone, and the claim TTL had no upper bound, so a claim older than the cutoff but still live could be deleted under a working worker, which then discarded its result. The purge now skips any row whose claim is younger than classification_claim_ttl, whatever its age, and reports them as :claims_kept (top level, beside :held, which stays for legal holds; the rake task prints it). classification_claim_ttl must be more than 0 and at most a day, or configuring raises with a clear message. Documented in the concern, Moderate::Attachments and docs/configuration.md: an adapter that raises leaves its claim until it expires and schedules no retry itself; the guard job, a job's recheck at expiry, a new save, or the job backend retrying the same job (same job id, so the claim is its own) takes it over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is checked as a number A TTL of 86400.5 passed the one-day cap through to_i; the purge counted a claim at the exact expiry instant both as deletable and as kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lines beta4 settled how the gem ships migrations: create_moderate_tables keeps the beta3 table shape, moderate:install copies the gem's migrations after it, and each gem migration alone creates what it adds, so its down never drops what the install needs. The 1.0 migration now does the same: its columns and moderate_classifications are out of the install template, the generator tests cover every gem migration (including a fresh install rolling the 1.0 one back to the install's schema), and the migration's comment and the CHANGELOG say how each kind of app gets it. The two temporary .rubocop.yml excludes for resolve_flag.rb and resolve_report.rb are gone, their continuation lines reformatted with the rest of beta4's new lines; generator test output is excluded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
playscbw's beta3 integration: a ban, then an unban in the host, then a
second ban decision in the same episode didn't call the ban_handler
(the earlier ban decision counted as covering it) yet recorded
"account_suspended", so the statement of reasons was false.
An optional config.banned = ->(user) { ... } says whether the person is
banned right now. When set, an earlier ban covers a new decision only
while it returns true; otherwise the ban_handler runs again. Unset, the
earlier ban counts, as before (documented beside ban_handler, and in
the initializer template).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
playscbw's beta3 integration: resolve! with no remove_content and no ban_user recorded "other_restriction" and sent the author a statement of reasons for a restriction nobody imposed. resolve! means a restriction (remove_content:, ban_user:, or both), as its doc always said; dismiss! is the decision that restricts nothing. With neither, resolve! now raises RecordInvalid with moderate.errors.resolve_needs_action (en/es) and the report stays open. Chosen over turning it into a dismissal: a silent change of status would misrecord a decision the moderator meant to take. Documented in the README, docs/madmin.md and the CHANGELOG (a behavior change for hosts that "resolved" with only a note). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
playscbw's beta3 integration: a second report on content an earlier decision already removed had no truthful way to close (flags had "already dealt with"; removing again removed nothing and was refused). resolve!(remove_content: true) now looks, under the content's lock, for an actioned report on the same content and field whose decision removed it, while the content is gone now (read fresh; deleted content counts, the earlier report matched through its snapshot since deletion nullifies every pointer). It records content_already_removed_by, removes and announces nothing again, sends the author no second statement unless it also bans, and tells the reporter the content is gone. Found on the way: Report#public_notice_requires_subject_url ran on every save, so a report whose content was later deleted couldn't be decided at all; it now runs at intake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
playscbw's beta3 integration, the smaller findings: - Report#reported_content_text read the live content although its doc said snapshot; it now returns the text captured at intake (surviving edits and deletion; nil once purged). Intake itself reads the live text through a private live_content_text. - moderate.errors.* and the Moderate::Moderation concern's flash messages existed only as inline English; en.yml and es.yml now carry them, and a test keeps every such key the code uses covered in both. - docs/madmin.md named a .resolved scope that doesn't exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's 1.0 advice from the beta4 pass. An app holding copies of the gem's migrations (*.moderate.rb, as every app installed from beta4 on does) runs only those, and the engine leaves the gem's own path out, so a release migration nobody copied after an upgrade would never run, silently. An after_initialize check compares the copies with the gem's migrations by name; when one is missing it raises Moderate::ConfigurationError naming it and `bin/rails moderate:install:migrations`. Under a rake task (that very command, db:migrate) it logs instead, so the fix can run. config.migration_copies_check = :raise (default) | :log | :off. Apps without copies aren't checked. Documented in the README upgrade note and docs/configuration.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's final pass, finding 1. The person's row was locked, but the user object handed to config.banned and to the ban_handler was the one loaded before the wait. Inside a host transaction that had read the person earlier, MySQL's REPEATABLE READ snapshot kept it stale, so two concurrent ban decisions both saw "not banned" and the second called the ban_handler again. ban_reported_user! now reads the person with a locking read once it holds their lock and passes that instance to config.banned and the ban_handler. Test: two ban decisions on one person at once, with the ban state in users.suspended_at and each inside a host transaction that read the person first: one handler call, the second records ban_already_applied_by (PostgreSQL and MySQL; on MySQL it fails without the fix, two calls). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's final pass, finding 2. covering_removal matched content id and field and checked the field was empty now; if the author reposted into the field after decision A, a report on the repost that was later deleted closed claiming A had removed it. The intake snapshot now records the content's updated_at (content_updated_at), and a decision covers another report's removal only when both snapshots carry the same content text and the same content_updated_at. When either can't name the revision (an older or purged snapshot, content without text), nothing is covered and the normal removal or refusal applies. Tests: the same content is covered; a repost of the same words into the same field is not; a snapshot without the revision is not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unreleased becomes `## [1.0.0] - 2026-10-19`: an introduction, Upgrading from 1.0.0.beta4 (the migration and what changes for a beta4 app), then Added / Fixed / Removed, the 1.0 feature set since 0.1.0 and Upgrading from 0.x, folded in from the old "[1.0.0] - unreleased" section so there is one 1.0.0 heading. Claims that are no longer true are corrected: config.notify no longer gates decision_notified_at (Moderate.delivered! does), the optional integrations are wired through hooks (only Turnstile is detected), the 0.x validator is the wordlist in :block mode, moderate:install copies the gem's migrations, the purge's return keys, and the attachment fix's description. lib/moderate/version.rb stays at 1.0.0.beta4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's pass on 9943b55: with update_column edits (no updated_at change), the same words reposted after decision A, a new report B, and the field cleared again matched A's snapshot (same text, same updated_at), so B got content_already_removed_by. Revisions are no longer inferred from the content's timestamps. A decision covers another report's removal only when that report was filed before the decision removed the content (its created_at against the decision's resolved_at), both intake snapshots carry the same, non-blank text, and the content is gone now. A report filed after a removal is never credited to it; the normal removal or refusal applies. content_updated_at is no longer recorded in the snapshot: nothing reads it. Tests: two open reports, A removes, B covered; B filed after A's removal on an update_column repost then cleared, not covered; B filed after the removal on content still gone, refused with "Nothing was removed"; a snapshot without text, not covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's pass on 3a92e27: comparing B.created_at with A.resolved_at trusts application clocks, so skew between servers, or a report filed between the removal and resolved_at, could still credit a repost. No clocks now. A decision that removes content stamps, under the content's lock it already holds, the other reports pending on that content and field whose intake snapshot holds the same text (moderate_reports.covered_by_report_id, indexed, in the 1.0 migration; Report#covered_by). Resolving a stamped report with remove_content is covered only if the stamping decision removed the content and the content is gone now. A report that wasn't pending when the content was removed is never stamped, whatever its timestamps. Tests: two open reports, A removes, B is stamped and covered; a report filed after the decision, even backdated, isn't stamped and is refused ("Nothing was removed"); a pending report with other or no text isn't stamped; a report whose intake commits while A holds the content lock is stamped only with the text that was removed, or refused (PostgreSQL and MySQL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's pass on 5342d24. (1) Stamping matched the other reports against A's intake snapshot: if A and B both captured the old text and the author then edited it, A removed the new text and still stamped B. A decision now reads, under the content's lock and right before removing, the text it is about to remove, records it as resolution_actions["removed_text_digest"], and stamps only open reports whose snapshot holds exactly that text; nobody when it is blank or unreadable. (2) A stamp stays as history, but covers only while the stamping decision stands: an upheld appeal against it voids the cover and the normal path runs. Tests: an edit between intake and removal leaves the old-text report unstamped and records the digest of what was removed; an upheld appeal voids the cover; the race fixture now removes text that differs from A's intake and asserts a stamp only carries the removed text (PostgreSQL and MySQL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex's pass on 685fd2d: the text was read before the host's remove_reported_field!, so a host method that rewrote the field to other text and returned true counted as a removal, and neither the stamp nor the digest was checked against what it actually did. After a true return the field is read again under the same lock (content_gone?, now asking moderation_snapshot as well as the field itself, and deleted?); unless it reads as gone, the decision is refused as "Nothing was removed", nobody is stamped and no digest recorded, and the host's rewrite rolls back with it. The stamp and the digest describe the text that was there right before a verified removal. The README's reportable section now states the seam's contract: remove the field's content as it is when called, persist it, return whether anything was removed, never rewrite it first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
moderate 1.0.0: the gem two real hosts (CarHey, playscbw) shaped, with the beta line's defects closed and the API frozen.
moderate_classificationstable (no lock held during the adapter call; claims expire; purge keeps live claims).remove_reported_field!; a second report on content already removed closes against the decision that removed it, by a stamp written under the content lock (no clocks); a ban after a host-side unban runs again (config.banned);resolve!without an action is refused; the person is re-read under its lock before a ban.Moderate.delivered!extends the six months from the moment the statement actually went out, never earlier, once.Moderate.purgewithredacted_at, legal holds (Moderate.hold),claims_kept, a retention statement in docs/compliance.md.:blockmode.Upgrading
Run
bin/rails moderate:install:migrations && bin/rails db:migrate(beta4's pending_key migration and 1.0's retention/classifications migration); see the CHANGELOG's "Upgrading from 1.0.0.beta4" and "Upgrading from 0.x".Changelog (excerpt)
The first stable release. The public API is frozen until 2.0: everything listed in the
README's "What is public" section keeps its name, arguments and meaning, and
test/public_api_test.rbkeeps that list honest. Since 1.0.0.beta4: Ruby 3.3+ and Rails8.0.5.1+, attachments classified once and only once their files exist, opt-in retention with
legal holds, an appeal window that runs from confirmed delivery, and one migration (see
Upgrading from 1.0.0.beta4). Coming from an earlier beta, apply each beta's Upgrading
notes below in order, then these; coming from 0.x, see Upgrading from 0.x at the end of
this entry.
Upgrading from 1.0.0.beta4
One migration: run
bin/rails db:migrate(afterbin/rails moderate:install:migrationsif your app holds copies of the gem's migrations,
*.moderate.rb, as every app installedfrom 1.0.0.beta4 on does; boot now fails, naming it, if you forget).
db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rbadds
redacted_attomoderate_reports,moderate_flagsandmoderate_appeals,held_untilandcovered_by_report_id(both indexed) tomoderate_reports, and createsmoderate_classifications. Itsorts after beta4's
pending_keymigration and needs no backfill. Like that one it is theonly thing that creates what it adds:
rails generate moderate:installleaves it out ofcreate_moderate_tablesand copies the migration after it, an app without copies gets itthrough the engine, and its
downdrops only what it created.What changes for an app upgrading from beta4 (each is detailed below):
json < 3, as before).config.signed_gid_purposesis gone: delete the line if you set it.report.resolve!with neitherremove_content:norban_user:is refused: dismissinstead.
decision_notified_at/affected_user_notified_at(and the appeal window'sextension) wait for
Moderate.delivered!from your mailer's delivery.Report#reported_content_textis the intake snapshot.config.banned.workaround.
ClassifyJobs per upload (the guard and theimmediate one); each set of files is still classified once.
Added
(
*.moderate.rb) runs only those, so a release migration it didn't copy would neverrun, silently. At boot (
after_initialize) the engine compares the copies with its ownmigrations by name and, when one is missing, raises
Moderate::ConfigurationErrornaming it and
bin/rails moderate:install:migrations; under a rake task (that command,db:migrate) it logs instead.config.migration_copies_check(:raise,:log,:off) changes it. Apps without copies aren't checked.everything that keeps its name, arguments and meaning until 2.0: the macros,
report!/block!, the reportable contract, the decision methods (resolve!/dismiss!/action!/uphold!/reject!) and their services,flag_for_review!,taxonomy_label,reportable_field_label,blocked_ids_for,purge,hold/release_hold,delivered!, every configuration key, the viewhelpers, the controller concern and its seams, the adapter contract, and the event
vocabulary with the notifier contract. The vocabulary is now code:
Moderate::Event::NOTIFY_NAMESandModerate::Event::AUDIT_NAMES.test/public_api_test.rbfails when a listed name doesn't exist, when a publicModerate.*method or configuration key isn't listed, when the event lists drift fromwhat the gem emits, or when the README or docs/ name a
Moderate…constant or method thatdoesn't exist (it caught
Moderate::DSA_LEGAL_REASONSin docs/compliance.md; it isModerate::Report::DSA_LEGAL_REASONS).@api private:Moderate.notify,.audit,.apply_ban,.announce_ban,.ensure_ban_handler!,.run_on_block,.with_row_lock,.register_reportable,.locale;Report::SIGNED_GLOBAL_ID_PURPOSE/APPEAL_SIGNED_GLOBAL_ID_PURPOSE,Report#signed_reportable_gid,#acknowledge!,#close_redress_window!,#block_reported_user,Report.locate_signed_appeal_report;DeferredNotices.defer/.connection_for/Envelope; the wordlist's compilerinternals.
Moderate::Engine::LIB_ROOT,MODERATE_LIBandZEITWERK_IGNOREDare privateconstants. Nothing tagged was removed; a host still calling one should move:
Moderate.apply_ban→flag.action!(ban_user: true)orreport.resolve!(ban_user: true)(which also send the statement of reasons and open the appeal window);
to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE)andreport.signed_reportable_gid→record.to_moderation_sgidormoderate_report_target_path(record, field:).auditfires for everyevent (
content_flagged, for one, is notify-only: the flag row is its record).Moderate.purge(older_than: 1.year, dry_run: false)and themoderate:purgerake task (OLDER_THAN_DAYS,DRY_RUN), opt-in. They redact the personaldata in reports, notices, flags and appeals whose decision is final (closed, decided more
than
older_thanago, appeal window over, no open appeal): messages, evidence snapshots,reported URLs, notifier and appellant contact details, flag excerpts and context. Every row
Review
Codex (gpt-6-sol): an adversarial review and nine confirming passes on this branch (attachments, retention, the appeal clock, the covering rule, the post-removal verification), the last: "APPROVED to release 1.0.0". Suites: 459 runs, 0 failures on SQLite (Ruby 3.3/3.4/4.0 × Rails 8.0/8.1), PostgreSQL 16 and MySQL 8.4; rubocop and bundler-audit clean.
🤖 Generated with Claude Code