Skip to content

1.0.0 - #11

Merged
rameerez merged 36 commits into
mainfrom
release/1.0.0
Oct 18, 2026
Merged

rameerez merged 36 commits into
mainfrom
release/1.0.0

Conversation

@rameerez

Copy link
Copy Markdown
Owner

moderate 1.0.0: the gem two real hosts (CarHey, playscbw) shaped, with the beta line's defects closed and the API frozen.

  • EOL dropped: Ruby >= 3.3, Rails >= 8.0.5.1 (the first 8.0 with no open advisories); the 7.1 and 7.2 appraisals gone, 8.0 and 8.1 in CI; bundler-audit and rubocop in CI at zero offenses.
  • Attachments: classification waits for the upload (direct uploads, server-side uploads that fail, replaced sets), claim-then-classify through a moderate_classifications table (no lock held during the adapter call; claims expire; purge keeps live claims).
  • Decisions that never lie: a removal is verified after the host's 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.
  • Appeal clock from delivery: Moderate.delivered! extends the six months from the moment the statement actually went out, never earlier, once.
  • Retention: Moderate.purge with redacted_at, legal holds (Moderate.hold), claims_kept, a retention statement in docs/compliance.md.
  • Public API frozen (README "What is public", a drift test), a boot check for missing copied migrations, en/es keys for every error and flash, the 0.x validator revived in :block mode.

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.rb keeps that list honest. Since 1.0.0.beta4: Ruby 3.3+ and Rails
8.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 (after bin/rails moderate:install:migrations
    if your app holds copies of the gem's migrations, *.moderate.rb, as every app installed
    from 1.0.0.beta4 on does; boot now fails, naming it, if you forget). db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb
    adds redacted_at to moderate_reports, moderate_flags and moderate_appeals,
    held_until and covered_by_report_id (both indexed) to moderate_reports, and creates
    moderate_classifications. It
    sorts after beta4's pending_key migration and needs no backfill. Like that one it is the
    only thing that creates what it adds: rails generate moderate:install leaves it out of
    create_moderate_tables and copies the migration after it, an app without copies gets it
    through the engine, and its down drops only what it created.

  • What changes for an app upgrading from beta4 (each is detailed below):

    • Ruby 3.3+ and Rails 8.0.5.1+ (Rails 8.0 hosts on json 3 need json < 3, as before).
    • config.signed_gid_purposes is gone: delete the line if you set it.
    • report.resolve! with neither remove_content: nor ban_user: is refused: dismiss
      instead.
    • decision_notified_at / affected_user_notified_at (and the appeal window's
      extension) wait for Moderate.delivered! from your mailer's delivery.
    • Report#reported_content_text is the intake snapshot.
    • If your app can lift a ban, set config.banned.
    • If you worked around the attachment upload race (CarHey's avatar), remove the
      workaround.
    • Attachment classification queues two ClassifyJobs per upload (the guard and the
      immediate one); each set of files is still classified once.

Added

  • A missed migration copy fails at boot. An app holding copies of the gem's migrations
    (*.moderate.rb) runs only those, so a release migration it didn't copy would never
    run, silently. At boot (after_initialize) the engine compares the copies with its own
    migrations by name and, when one is missing, raises Moderate::ConfigurationError
    naming 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.
  • The public API is frozen and listed. The README's new "What is public" section names
    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 view
    helpers, 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_NAMES and Moderate::Event::AUDIT_NAMES.
    test/public_api_test.rb fails when a listed name doesn't exist, when a public
    Moderate.* method or configuration key isn't listed, when the event lists drift from
    what the gem emits, or when the README or docs/ name a Moderate… constant or method that
    doesn't exist (it caught Moderate::DSA_LEGAL_REASONS in docs/compliance.md; it is
    Moderate::Report::DSA_LEGAL_REASONS).
  • What isn't public is marked. @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 compiler
    internals. Moderate::Engine::LIB_ROOT, MODERATE_LIB and ZEITWERK_IGNORED are private
    constants. Nothing tagged was removed; a host still calling one should move:
    Moderate.apply_ban → flag.action!(ban_user: true) or report.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) and
    report.signed_reportable_gid → record.to_moderation_sgid or
    moderate_report_target_path(record, field:).
  • docs/notifications.md lists the audit events instead of claiming audit fires for every
    event (content_flagged, for one, is notify-only: the flag row is its record).
  • Retention: Moderate.purge(older_than: 1.year, dry_run: false) and the
    moderate:purge rake task (OLDER_THAN_DAYS, DRY_RUN), opt-in. They redact the personal
    data in reports, notices, flags and appeals whose decision is final (closed, decided more
    than older_than ago, 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

rameerez and others added 30 commits October 18, 2026 23:50
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>
rameerez and others added 6 commits October 19, 2026 00:16
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>
@rameerez
rameerez merged commit 9729fd2 into main Oct 18, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant