diff --git a/AUTO-APPROVE-FOR-MODERATORS.md b/AUTO-APPROVE-FOR-MODERATORS.md
new file mode 100644
index 0000000000..92ee43ed1e
--- /dev/null
+++ b/AUTO-APPROVE-FOR-MODERATORS.md
@@ -0,0 +1,218 @@
+# Auto-Approve - A Guide for Moderators
+
+> **Current reference document.** Explains post-moderation (auto-approve) for
+> moderators. Read alongside [`RIPPLING-OUT-FOR-MODERATORS.md`](RIPPLING-OUT-FOR-MODERATORS.md) -
+> the two features interact, and the section "How this works with rippling out"
+> below is where they meet.
+
+**The change in one sentence:** a content-checked, clean post from a settled, auto-moderated
+member goes live by itself after a short delay (about 20 minutes by default), and you review
+it **after** it is live in a dedicated oversight queue, instead of approving it beforehand.
+
+**The state of the decision:** this is **built but switched off**. Nothing changes on any
+group until it is deliberately switched on, and the plan is to start with a small number of
+volunteer trial groups and published error rates, not a network-wide flip. The final
+decision rests with the Board, informed by the trial.
+
+---
+
+## The objections, taken seriously
+
+This change was discussed at length by moderators in
+[Evolution of our approach](https://discourse.ilovefreegle.org/t/evolution-of-our-approach-improving-the-freegle-experience/9620)
+(over 300 posts). Those objections shaped what was actually built. Before anything else,
+here is each substantial objection raised there and an honest statement of how the design
+answers it - or does not. Post numbers refer to that thread.
+
+| The objection | Honest response |
+|---|---|
+| **"You've already decided - this consultation is fake."** (many moderators; posts 31, 78, 118, 138, 290) | The criticism of the process is fair: progress was announced in a passing comment on another thread, and that was handled badly. The only honest test of whether consultation is real is whether input changes outcomes, so judge it on that: the objections in this thread are why explicitly-moderated members stay moderated, why member notes veto auto-approval, why oversight is a queue rather than a board-scan, why there is a quality-check control sample and a published error rate at all, and why the rollout is a small opt-in trial rather than a network-wide switch. The decision to go beyond a trial has not been made, "no" remains an available answer, and it should be made against error-rate results shared with moderators - with the success and failure criteria stated before the trial starts, not after. |
+| **"Where's the evidence this is a problem? 97% approval could just mean moderation works."** (many moderators; posts 26, 30, 51, 137) | Partly conceded: we cannot prove in advance that faster posting grows Freegle. That is exactly why the build includes measurement rather than faith: every auto-published post is tracked, a random sample can be held back for human checking as a control, and the **auto-publish error rate** (how often a mod later had to step in) is computed continuously. If the trial shows the error rate is too high or the benefit too small, it gets switched off again and the numbers will show why. One honest limitation: the analytics page currently sits under SysAdmin, which ordinary moderators cannot see - so trial results must be actively published to moderators (on Discourse), not just "available". Widening who can see that page is a fair ask. |
+| **"A bad post is live before anyone can stop it - post-hoc review can't undo harm."** (several moderators; posts 5, 8, 29, 68, 132) | True, and not spun away: with auto-approve on, a bad post that passes the filters is publicly visible until someone acts. What bounds the harm: every post still goes through the content checks **before** publication; a suspect post stays in Pending exactly as now; there is a hold window (default 20 minutes) before anything goes live, during which it sits in your Pending queue and can be stopped; and a live post can be pulled straight back from the oversight queue with one click. Wider spread is also gated - see the rippling row below. The exposure is bounded, not zero. That is the trade being made, and the error rate will tell us its real size. |
+| **"The filters already miss dangerous things - phone numbers, addresses, child-safety risks."** (several moderators; posts 2, 4, 54, 277) | Also true - no filter is perfect, and specific misses reported in the thread (like multi-word phrases such as "discounted price") have already led to filter fixes. But note what does *not* change: the content checks run before publication for everyone, exactly as today, and a flagged post never auto-approves. The gap is posts that are dangerous in ways no filter recognises. Those get through **today** for the roughly half of posts that already publish instantly (trusted and unmoderated members). Auto-approve extends instant-ish publishing to more members, with *more* safeguards than the trusted tier has ever had: a delay, a danger-signal check on the poster, and an oversight queue. |
+| **"New members post incoherent rubbish - 'Any', 'Pond fish' - and no worry-word list catches that."** (several moderators; posts 13, 95, 112, 209, 306, 309) | The weakest spot in the design, said plainly. Vague-but-harmless posts are not what the filters or danger signals catch, so under auto-approve some will go live that you would have caught in Pending. What softens it: they appear in your Checked queue so they are findable without trawling the board, and your group can lengthen its own delay (for example to a few hours) to widen the catch window. If the trial shows Wanteds dominate the error rate, restricting auto-approve by post type (Offers first, as suggested in posts 13 and 308) is the obvious next lever - that is not built yet, and the error-rate split by type will show whether it is needed. |
+| **"I keep certain members on moderation for good reasons. Automation can't know that."** (several moderators; posts 100, 114, 123, 134, 162) | This one is directly and fully honoured. A member you have explicitly set to **Moderated** (or Prohibited) is completely untouched - their posts wait for you, forever, exactly as now. Auto-approve only applies to members with **no explicit setting**. And even for those, it declines to act when there is any recorded reason for caution: a note on the member, a microvolunteer rejection on the post, a negative moderation action in the last 90 days, a spammer record, or an outstanding membership review. If you have ever written a note about someone, that note keeps them in Pending. |
+| **"Scammers and rule-breakers will exploit unchecked posting - they already reword to dodge filters."** (several moderators; posts 114, 122, 213, 216) | Known spammers and suspected spammers never auto-approve (any spammer record vetoes it, and a post in the Spam collection on *any* group blocks approval on all of them). A repeat offender your team has actioned in the last 90 days does not auto-approve either. The honest gap: a **first-time** bad actor with a clean record can get one post live for a while. That post is in the oversight queue, can be rejected in one click, and doing so creates exactly the negative history that stops their next one. |
+| **"Members don't report bad posts. The 'eagle-eyed members' safety net is imaginary."** (posts 30, 110, 114) | Agreed, and the design does not rely on member reports. The active safety nets are the ones we run: your oversight queue, microvolunteer checks, the quality-check sample, and the automated checks. Member reports remain a bonus, not the mechanism. |
+| **"This means MORE work: wading through live posts, fixing errant ones, thanking reporters."** (several moderators; posts 5, 87, 184, 306, 311, 312) | You do not have to wade through the board - that is what the **Checked** and **Trusted** queues are for. They contain exactly the posts that went live without a human, newest first, with a count of how many you have not yet seen, a one-click "mark all as checked", and a 7-day window so they never pile up into a mountain. The point made in post 311 - that rippling has already made board-scanning impractical - is precisely why oversight is a queue and not a scan. Whether *total* work goes up or down is measured in the trial, not asserted: if 97% of held posts genuinely need nothing, clicks should fall. If the fallout work exceeds the saved clicks - one moderator reported exactly that from an earlier local experiment (post 41) - the numbers will show it. |
+| **"Groups set their rules deliberately. This overrides local autonomy with central control."** (several moderators; posts 25, 87, 90, 130, 165) | Stated plainly rather than softened: post-moderation is intended as how Freegle works, network-wide - groups will not have a setting to opt out of it, because a patchwork of moderation models would confuse members and undo the point of the change. What stays under local control: every explicit member posting status you set is honoured absolutely (a member you keep on Moderated waits for you, forever), your notes on members veto auto-approval, your group tunes its own delay and quality-sample percentage, and your rules are enforced by you exactly as now - rejecting a rule-breaking post is unchanged. Rippling questions belong to the rippling guide. |
+| **"This is really about making moderators redundant. Take away the work we enjoy and we leave."** (posts 237, 245, 279 - and several moderators said they would resign, posts 34, 41, 141) | No code change answers a feeling of being devalued, and people quitting over this is a real, already-partly-realised cost. What is factually true: the judgment work stays yours - suspect posts, flagged members, reports, rejections, member disputes all still come to you, and the routine 97% is what goes. The stated aim is redirecting volunteer attention, not removing it. Whether that is how it plays out is fair to judge by results, and the trial-first, switched-off-by-default rollout exists so that judgment can happen while it is still reversible. |
+| **"Human pre-moderation is Freegle's trust differentiator - dropping it risks members' and funders' confidence."** (several moderators; posts 27, 97, 103, 158) | A judgment call, honestly labelled as such. Two facts for the scales: about half of Freegle posts already publish without pre-moderation today (trusted and unmoderated members), and that has been true for years without being the thing members or funders notice; and human oversight does not disappear - it moves after publication and stays visible and provable (every check is recorded, see the last section). Freegle remains far more moderated than any of the platforms it is compared to. |
+| **"Instant posting breeds fastest-finger-first and undermines Fair Chance."** (posts 35, 114) | The delay means posting is not instant, and nothing in this change alters reply handling or allocation. But the underlying point is fair: faster publication does reward quick responders, and leaning on a moderation delay to blunt that was always indirect. Fair Chance itself is a very old model, and a better one is worth designing in its own right - for example a short reply-gathering window on each post before the offerer chooses, so being first matters less than being suitable. Faster publication makes that work more worthwhile, not less; it deserves its own proposal rather than a footnote here. |
+| **"Pilot it properly or not at all."** (many moderators; posts 37, 49, 73, 170, 280) | That is the plan, and the code enforces it: switched off everywhere by default, an explicit trial-group list for a phased start, live error-rate measurement, and a reversible switch. No big bang. |
+| **"A bad auto-approved post won't just hit my group - rippling will spread it to my neighbours."** (posts 302, 306) | Addressed by the **earned-reach gate**: an auto-published post (one no human has looked at) waits about an hour before it starts rippling at all, and it then spreads only as fast as clean exposure accumulates - each additional community it reaches requires that a number of members have already seen the post with nobody flagging it. A single member report, or a microvolunteer rejection, pauses further spread until a moderator looks; a moderator check clears it, and a rejection pulls it back. Posts a moderator approved by hand are exempt, because they have had their human look. (Requiring active reviewer sign-off per community was considered and dropped - review capacity could never keep up with post volume, so it would have quietly stopped rippling for most posts.) (The observation in post 302 - instant rippling with no delay - is right, and worth being precise about: by the thread's own figures (post 1), only around 46% of posts are held for human approval, so roughly half of what ripples today was never human-approved either - it passed the automated checks and went. The hold and the exposure gate arrive with auto-approve precisely to give unreviewed posts a brake that today's rippling does not have.) |
+| **"Something illegal goes live - flytipped goods, kerbside free-for-alls. Who is liable? Me?"** (posts 4, 226, 238, 258) | Legal responsibility for a post sits with the person who posted it - that principle is what lets any user-content platform operate, and it does not change here. What Freegle controls is how fast a problem post is caught: the checks before publication, the hold window, your oversight queue and the one-click reject are that machinery. Groups with rules about kerbside posts keep enforcing them exactly as now - nothing about auto-approve weakens a rejection. |
+
+If you raised something in that thread that is not represented above, or the response
+misstates your point, say so on the thread - this table is meant to be kept honest.
+
+---
+
+## The one big change
+
+> ### Settled members' posts go live first, and you review them after - not before
+>
+> Until now, a post from an **auto-moderated member** (one with no explicit posting
+> status) sat in **Pending** until a moderator approved it. With auto-approve switched
+> on, a content-checked, clean post from such a member goes live by itself after a short
+> delay (about 20 minutes by default), and you review it **after** it is live, in a
+> dedicated oversight queue.
+>
+> This is deliberate. It gets good posts moving quickly and focuses your attention on the
+> small number that actually need a human, instead of rubber-stamping the majority that
+> are approved unchanged.
+
+Nothing about a **suspect** post changes: if the content check flags it, or there is any
+danger signal (a mod note on the member, a negative moderation action in the last 90 days,
+a known-spammer record, a pending membership review, a microvolunteer rejection), it
+**stays in Pending** exactly as before and waits for you.
+
+For scale: roughly half of all posts already publish without pre-moderation today, because
+they come from members you (or defaults) have marked trusted. Auto-approve extends
+quick publishing to the auto-moderated tier, with more safeguards attached than the
+trusted tier has.
+
+---
+
+## Switched off until deliberately switched on
+
+The feature is **dark by default**. Merging and deploying the code changes nothing.
+Rollout, when it comes, is phased:
+
+- A **trial-group list** lets it run on a handful of volunteer groups first, everywhere
+ else untouched.
+- Per group, the **delay** and the **quality-check sample percentage** are configurable.
+- There is no per-group setting to opt out of post-moderation itself: once rolled out it
+ is how Freegle works everywhere, so members get one consistent experience. Member-level
+ control (explicit posting statuses, notes) is untouched.
+- The **error rate is measured from day one** (see the last section), so the decision to
+ widen, hold or reverse is made on numbers.
+
+---
+
+## What still comes to you first (Pending)
+
+The Pending queue is still the full queue, and you can still stop anything here before it
+goes live. Two things are new:
+
+- **A live countdown.** Each post that is on the clean auto-approve path shows a small
+ countdown to when it will auto-approve - a muted "~Nh" when it is more than an hour away,
+ a prominent "Mm Ss" inside the last 30 minutes, and "Auto-approving..." at zero. Posts
+ that are **not** auto-approving (suspect, spam, held, or feature off for your group) show
+ no 20-minute countdown.
+- **A guaranteed review window.** The moment you open the Pending queue, every post on it
+ is given **at least 10 more minutes** before it can auto-approve. Nothing ever
+ auto-approves out from under you while you are looking at the page.
+
+---
+
+## The new queues
+
+Alongside **Pending** and **Approved** you now have two oversight queues:
+
+- **Checked** - posts that went live via the automatic checks (your auto-moderated
+ members). This is your **oversight queue**: a chance to glance over what published
+ without you.
+- **Trusted** - posts that went live without moderation because they came from a
+ **trusted** member (set in your group settings). Same idea, different reason they
+ skipped review.
+
+The counts on **Checked** and **Trusted** show **how many are still unchecked** (shown in
+blue, not the red of Pending), and they drop as you check things off. A **"Mark all as
+checked"** button clears the bucket in one go. Posts older than **7 days** fall off the
+queue on their own, so it always reflects recent activity rather than growing forever.
+
+Posts that merely **rippled in** from another community do not appear in your oversight
+queues - overseeing those is the job of the community they were posted to, plus the
+review that gates their spread (below).
+
+---
+
+## Rejecting a post after it has gone live
+
+The oversight queues are not just for looking - they are **actionable**. If you find a
+post in **Checked** or **Trusted** that should not be live, you can **reject it straight
+from that queue**. It is pulled back out of the live feed and returned to Pending/held so
+it can be dealt with properly, exactly as if it had never auto-approved.
+
+This is the safety net for the rare post that slips through the automatic checks: it went
+live quickly, but you can take it down just as quickly.
+
+---
+
+## How this works with rippling out
+
+This is where auto-approve and rippling out meet, so it is worth understanding.
+
+A live post can **ripple out** to neighbouring communities (see the rippling guide). A
+post that auto-approved has not been looked at by a human, so we do **not** let it
+spread network-wide unchecked. Instead, **reach is earned by exposure without
+complaint** - the people who have already seen the post vouch for it by not objecting:
+
+- **A head start before any spread.** A freshly auto-approved post waits about **an hour**
+ before it starts rippling at all, giving its home community first look.
+- **Wider reach needs more quiet eyes.** Each additional community a post ripples into
+ requires that a number of members have **already seen it and nobody has flagged it**.
+ Views accumulate naturally as people browse, so a normal post earns its spread simply
+ by being seen; a post nobody is seeing stays local.
+- **One complaint pauses it.** A member reporting the post to the mods, or a
+ microvolunteer rejecting it, **pauses all further spread** until a moderator looks.
+ Copies it has already reached stay put; it just goes no further.
+- **A moderator look settles it.** Your check clears the gate entirely (a human look
+ outranks the crowd signal); your rejection pulls it back. Requiring active
+ microvolunteer or moderator sign-off for every community was considered and dropped:
+ review capacity could never keep pace with post volume, and rippling would have
+ quietly stopped working for most posts.
+- **Mod-approved posts are exempt.** A post you approved by hand has had its human look
+ and ripples normally.
+
+The short version: **a post spreads as far as the quiet approval of the people who have
+already seen it - and one objection stops it until a human decides.**
+
+---
+
+## Who does the reviewing
+
+The earning is passive - members seeing the post as they browse. The active roles are
+the brakes and the all-clear, and both are drawn from **inside the post's maximum ripple
+area, nearest first**:
+
+- **Microvolunteers** inside the area the post can reach are asked to look, with the
+ people **closest to the post** asked first - they are the ones most likely to have
+ useful local context. Their rejection pauses the post's spread.
+- **Moderators** of communities inside that same area can check a post (clearing its
+ gate) or reject it (pulling it back). A moderator of a community the post will never
+ reach is never involved - which is only fair, since it was never going to land on them.
+
+In short: everyone who sees the post quietly vouches for it; the people it can actually
+reach, closest first, hold the brake.
+
+---
+
+## What to expect, and the habits to build
+
+1. **Nothing changes until it is switched on for your group.**
+2. **Posts being live before you saw them is the design, not a fault.** Your job shifts
+ from approving everything up front to keeping half an eye on the **Checked** and
+ **Trusted** queues and acting on the rare one that is wrong.
+3. **Use the reject action** for a genuinely unsuitable post you find live - it pulls it
+ straight back.
+4. **Your member notes matter more than ever.** A note on a member keeps their posts in
+ Pending. If you know a reason someone needs a human eye, write it down.
+5. **"Out of area" is still never a reason to reject** (see the rippling guide). A post
+ reaching you from a neighbouring community is the system working, not a fault.
+
+Everything else about your day-to-day moderation routine stays the same.
+
+---
+
+## Reference: how we know a post was checked
+
+When you check an auto-published post (individually or via "Mark all as checked"), we
+record **who** checked it and **when**. This is what makes the unchecked counts drop, and
+it feeds the **Moderation** analytics under SysAdmin: where posts went (arrived, approved
+or rejected by hand, auto-approved, trusted), the **auto-publish error rate** - how often
+an auto-published post later needed a moderator to step in - and how the held-back
+quality-check sample compares against it. Those numbers are how the trial gets judged,
+how auto-approve is tuned, and how it would be caught out if the objections above turn
+out to be right.
diff --git a/docker-compose.yml b/docker-compose.yml
index 485ef16dd3..e9a2a32281 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -717,6 +717,13 @@ services:
# render — matching how V1 derives its base from TUS_UPLOADER.
- UPLOADS=http://tusd:8080/tus/
- PARTNER_KEY_FILE=/run/secrets/PARTNER_KEY
+ # Auto-approve rollout gate: the Go Pending countdown (autoapproveat) must agree
+ # with the Laravel clean-path cron about whether the 20-minute path is on, or mods
+ # would see countdowns that never fire. Mirror whatever the batch env sets.
+ - FREEGLE_AUTOAPPROVE_ENABLED=${FREEGLE_AUTOAPPROVE_ENABLED:-}
+ - FREEGLE_AUTOAPPROVE_TRIAL_GROUPS=${FREEGLE_AUTOAPPROVE_TRIAL_GROUPS:-}
+ # Earned-reach review gate (reviewer-pool bounding in microvolunteering.go).
+ - RIPPLE_EARNED_REACH_ENABLED=${RIPPLE_EARNED_REACH_ENABLED:-}
- LOKI_ENABLED=true
- LOKI_URL=http://loki:3100
- LOKI_JSON_PATH=/var/log/freegle
@@ -912,6 +919,13 @@ services:
- RIPPLE_ANALYTICS_SAMPLE=${RIPPLE_ANALYTICS_SAMPLE:-250}
- RIPPLE_ANALYTICS_CONCURRENCY=${RIPPLE_ANALYTICS_CONCURRENCY:-16}
- PARTNER_KEY_FILE=/run/secrets/PARTNER_KEY
+ # Auto-approve rollout gate: the Go Pending countdown (autoapproveat) must agree
+ # with the Laravel clean-path cron about whether the 20-minute path is on, or mods
+ # would see countdowns that never fire. Mirror whatever the batch env sets.
+ - FREEGLE_AUTOAPPROVE_ENABLED=${FREEGLE_AUTOAPPROVE_ENABLED:-}
+ - FREEGLE_AUTOAPPROVE_TRIAL_GROUPS=${FREEGLE_AUTOAPPROVE_TRIAL_GROUPS:-}
+ # Earned-reach review gate (reviewer-pool bounding in microvolunteering.go).
+ - RIPPLE_EARNED_REACH_ENABLED=${RIPPLE_EARNED_REACH_ENABLED:-}
- LOKI_ENABLED=true
- LOKI_URL=http://loki:3100
- LOKI_JSON_PATH=/var/log/freegle
diff --git a/docs/superpowers/specs/2026-06-25-autoapprove-earned-reach-review-gate-design.md b/docs/superpowers/specs/2026-06-25-autoapprove-earned-reach-review-gate-design.md
new file mode 100644
index 0000000000..7997866020
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-25-autoapprove-earned-reach-review-gate-design.md
@@ -0,0 +1,163 @@
+# Auto-Approve x Rippling: Earned-Reach Review Gate - Design
+
+> Status: design, for review. Extends PR #639 (post-moderation) to be safe under
+> live rippling-out. Mod-facing summary: `AUTO-APPROVE-FOR-MODERATORS.md`.
+> Background + interaction review: memory `finding_pr639_stale_vs_rippling.md`,
+> workflow `w9jtr672a`.
+
+## Problem
+
+Pre-#639, every NULL-status member's post was human-approved before it could enter
+`messages_spatial` and therefore before it could ripple. #639 auto-publishes clean posts
+(~20 min, `collection=Approved`, `approvedby=NULL`), so an **unreviewed** post becomes
+ripple-eligible ~26 min after arrival and can spread network-wide before any human looks.
+The C4/C9/D1 guards (already merged + CI-green) stop rippled-in rows polluting the
+oversight queues, but they do not address the core issue: **reach currently runs ahead of
+review**.
+
+## Principle: reach is earned by review
+
+Auto-published posts spread only as far as their review supports. Well-reviewed posts go
+fast and far; unreviewed ones stay local. Review comes from two pools (microvolunteers and
+moderators) feeding one window.
+
+### The numbers (agreed)
+
+- **Review weight**: microvolunteer approve = **1**; moderator check = **2**.
+- **Required weight to be rippled into N communities = 2 x N.** (2 new communities => 4
+ microvol approvals, or 2 mod checks, or any mix to weight 4.)
+- **Reject is easy and does NOT scale**: 2 microvol rejects, or 1 mod reject, on any copy
+ halts expansion and withdraws that copy.
+- **~1h hold** before the first ripple (origin reviewers get first look).
+- **Capped reach (b1)**: if weight does not keep up, the post simply stops expanding. It is
+ not delayed-then-released; it stays at its current reach. Already-reached copies remain
+ until a reject withdraws them.
+
+### Threshold: global, scaled by N (not strictly per-group-local)
+
+"Per rippled-out group" sets the **threshold** (2 x N); the **sources** are pooled across
+the post's max ripple area (not tied to one receiving group). So the gate is:
+
+> cumulative review weight for the post >= 2 x (number of communities it has rippled into),
+> where any in-area microvolunteer approve counts 1 and any in-area moderator check counts 2.
+
+Both pools share the same in-area catchment and closeness ordering (see Reviewer pools), so
+"scaled per group" governs how much review is needed while the area bound governs who may
+supply it. A strictly per-group "2 of *this* group's own microvols" model is not used.
+
+## Reviewer pools
+
+**Both pools are drawn from within the post's maximum ripple area, ordered by closeness to
+the post.** Same catchment, same ordering - we ask the people the post can actually reach,
+nearest first.
+
+- **Microvolunteers** - eligible if they are inside the post's max ripple area; the
+ CheckMessage challenge selection (`getApprovedMessageChallenge`, Go) is ordered by
+ distance from the member to the post (nearest first). Existing eligibility (not own post,
+ trust gates, one-vote-per-(user,msgid)) is unchanged; we add the max-ripple-area bound +
+ closeness ordering and drop the implicit own-group-only restriction.
+- **Moderators** - a mod check counts toward weight only if it comes from a community
+ inside the post's max ripple area. In practice a mod check is recorded
+ (`messages_groups.checkedat`) against the origin row or a rippled-in row, and those
+ communities are within reach by construction.
+
+The closeness ordering is selection/surfacing only; any in-area approve/check still counts
+the same weight (microvol = 1, mod = 2) wherever in the area it comes from.
+
+## Components
+
+1. **Ratification weight (read model).** A function, given `msgid`, returns current weight:
+ `(# microactions WHERE msgid=? AND result='Approve')` + `2 * (# in-area
+ messages_groups rows for the msgid WHERE checkedat IS NOT NULL)`. Lives in the batch
+ ripple path (PHP), mirrored in Go only if the countdown/UI needs to show it.
+
+2. **ExpandService expansion gate (PHP).** Two changes in
+ `iznik-batch/app/Services/Ripple/ExpandService.php`:
+ - **Hold**: do not `initialiseNew()` a post until `arrival <= NOW() - INTERVAL 1 HOUR`
+ OR it has a mod check (`checkedat`). (Auto-published posts only; mod-approved/manually
+ approved posts are unaffected because they carry `approvedby`/`checkedat`.)
+ - **Earned-reach cap**: before rippling into the next community(ies) (the
+ `rippleIntoNewGroups` / reach-advance step), require `weight(msgid) >= 2 *
+ (currentRippledGroups + newGroupsThisStep)`. If not met, **pause** this post's
+ expansion this run (leave reach + existing rippled-in rows untouched); it resumes a
+ later run once weight catches up.
+
+3. **Reject action (Go + ModTools).** `iznik-server-go/message/markchecked.go`: add a
+ `Reject` path to the oversight endpoint (`collection='Pending'`, set `heldby`,
+ `checkedat=NULL`) scoped to the mod's group; ModTools Checked/Trusted pages get a Reject
+ control alongside "Mark as checked". A mod reject on the origin copy halts ripple (the
+ post leaves `messages_spatial`, so ExpandService stops); on a rippled-in copy it trims
+ that community (mirrors the existing rippled-in secondary-reject behaviour).
+
+4. **Microvolunteer reject quorum (existing).** 2 microvol rejects already call
+ `sendForReview()` (Approved->Pending on that group). For the origin copy this removes it
+ from `messages_spatial` and so halts ripple - reuse as-is; just confirm ExpandService
+ halts cleanly when the origin row is no longer Approved.
+
+5. **Review-gate observability (the delay we are imposing must be visible).** Capping reach
+ on under-review silently slows rippling, so we instrument and surface it:
+ - **Instrument**: when the gate pauses a post (weight < 2N), stamp the start of the
+ await-review hold; clear it when the post resumes (weight caught up) or terminates
+ (rippled to its full extent / withdrawn / capped-out). Store on `rippling_reach`
+ (e.g. `awaiting_review_since` nullable + `awaiting_review_seconds` accumulator), so a
+ post that pauses/resumes repeatedly accrues total delay.
+ - **Surface on the SysAdmin rippling page** (`ModSysAdminRippling.vue` <-
+ `iznik-server-go/.../rippling/metrics.go`, the existing dashboard with the reply/reach
+ KPIs). New tiles/series over the page's date range:
+ - **Posts currently awaiting review** (count) and **median time awaiting review**.
+ - **Reach delayed by review** - total await-review post-hours over the range (how much
+ rippling the review gate is holding back).
+ - **Capped-out posts** - posts that ended below their reachable extent purely because
+ review never arrived (the reach we are forgoing for safety), vs those that resumed.
+ This is the dial that tells us whether the review pools can keep up, or whether the
+ `2 x N` weighting / 1h hold needs tuning.
+
+## Data / flow
+
+- Weight is derived from `microactions` + `messages_groups.checkedat` (no new tally table
+ for v1; add a cached `messages.ratify_weight` only if the per-minute loop proves hot).
+- The await-review instrumentation **does** need persisted state on `rippling_reach`
+ (`awaiting_review_since`, `awaiting_review_seconds`) so the dashboard can report delay
+ without reconstructing it from logs. Additive migration.
+- `ripple:expand` already runs every minute and is the natural place to re-evaluate the
+ gate and update the await-review accumulator, so paused posts resume automatically as
+ weight accrues and the delay metric stays current.
+
+## Testing
+
+- **PHP (ExpandService)**: hold (no ripple < 1h, ripples >= 1h or with a check); cap (N=1
+ needs weight 2, N=2 needs weight 4; weight from microvol approves vs mod checks vs mix;
+ paused post resumes when weight catches up; reject/halt).
+- **Go**: markchecked Reject path (collection/heldby/scope/403s); challenge selection
+ closeness ordering + network-wide pool; mod-check-in-area weighting boundary;
+ `metrics.go` await-review fields (currently-awaiting count, median wait, delayed
+ post-hours, capped-out vs resumed) over a date range with exact counts.
+- **PHP**: await-review accumulator (pause stamps `awaiting_review_since`, resume adds to
+ `awaiting_review_seconds`, repeated pause/resume accrues correctly).
+- **Vitest**: Reject control on Checked/Trusted pages; the new await-review tiles/series on
+ `ModSysAdminRippling.vue`.
+
+## Out of scope (sibling spec)
+
+Chat "rippled-out review": extend `widerchatreview` (already UNIONs flagged chats across
+opted-in groups, `chatmessage.go:955-1021`, currently single-mod/no-quorum) with the same
+weighting + quorum + active widening. Separate spec + PR after this lands.
+
+## Decisions (defaults taken; veto any on review)
+
+1. **Capped reach (B1)** - confirmed. No quorum => the post stops expanding and stays local;
+ not delayed-then-released. The forgone reach is measured (component 5) so we can see the
+ safety/reach trade-off.
+2. **Global threshold scaled by N** - `weight >= 2N`. Both pools (microvols + mods) are
+ drawn from within the post's max ripple area and surfaced **ordered by closeness**; any
+ in-area approve/check counts its weight (microvol 1, mod 2) regardless of where in the
+ area it came from. The strictly per-group-local model is not used.
+3. **In-area catchment** - "max ripple area" is the post's full reachable polygon. A mod
+ check is counted via `messages_groups.checkedat` on the post's own rows (origin /
+ rippled-in), which are in-area by construction; microvol challenge selection is bounded
+ to the same polygon and distance-ordered.
+4. **Hold exemption** - a manually/mod-approved post (`approvedby` set) skips the 1h hold
+ and ripples immediately; the hold and the earned-reach cap apply only to auto-published
+ posts (`approvedby IS NULL`).
+
+Anything to change here is cheap to revise before build; otherwise these stand.
diff --git a/file-sync.sh b/file-sync.sh
index 519345820d..672ba21608 100755
--- a/file-sync.sh
+++ b/file-sync.sh
@@ -109,11 +109,14 @@ get_container_info() {
elif [[ "$relative_path" == iznik-server-go/* ]]; then
echo "${CN}-apiv2 /app/${relative_path#iznik-server-go/} API-v2"
elif [[ "$relative_path" == iznik-batch/* ]]; then
- # Skip bootstrap/cache files - these are generated by Laravel and should not be synced
- # Syncing these causes race conditions when Laravel regenerates them during tests
- if [[ "$relative_path" != iznik-batch/bootstrap/cache/* ]]; then
- echo "${CN}-batch /var/www/html/${relative_path#iznik-batch/} Batch"
- fi
+ # NO sync for iznik-batch: the batch container BIND-MOUNTS ./iznik-batch at
+ # /var/www/html (docker-compose.yml), so the container already sees every host
+ # edit instantly. Worse than pointless, syncing is destructive: docker cp onto
+ # the bind mount rewrites the very file that triggered the event (unlink +
+ # recreate), which re-fires inotify and loops the sync forever; and a racing
+ # do_delete during that unlink window runs `docker exec rm` THROUGH the bind
+ # mount, deleting the file on the HOST - edits silently vanish from the tree.
+ :
fi
}
diff --git a/iznik-batch/app/Console/Commands/Message/AutoApproveCleanCommand.php b/iznik-batch/app/Console/Commands/Message/AutoApproveCleanCommand.php
new file mode 100644
index 0000000000..c3ccf691c2
--- /dev/null
+++ b/iznik-batch/app/Console/Commands/Message/AutoApproveCleanCommand.php
@@ -0,0 +1,48 @@
+registerShutdownHandlers();
+
+ $dryRun = (bool) $this->option('dry-run');
+
+ if ($dryRun) {
+ $this->info('DRY RUN — no changes will be made.');
+ }
+
+ Log::info('Starting auto-approve-clean processing', ['dry_run' => $dryRun]);
+ $this->info('Processing content-check-clean pending messages for auto-approval...');
+
+ $stats = $service->process($dryRun);
+
+ $prefix = $dryRun ? '[DRY RUN] ' : '';
+ $this->info(
+ "{$prefix}Approved: {$stats['approved']}, Held (quality): {$stats['held_quality']}, " .
+ "Vetoed: {$stats['vetoed']}, Skipped: {$stats['skipped']}, Errors: {$stats['errors']}"
+ );
+
+ if ($stats['errors'] > 0) {
+ $this->warn("Errors: {$stats['errors']}");
+ }
+
+ Log::info('Auto-approve-clean processing complete', $stats);
+
+ return $stats['errors'] > 0 ? Command::FAILURE : Command::SUCCESS;
+ }
+}
diff --git a/iznik-batch/app/Services/AutoApproveCleanService.php b/iznik-batch/app/Services/AutoApproveCleanService.php
new file mode 100644
index 0000000000..9fab418d06
--- /dev/null
+++ b/iznik-batch/app/Services/AutoApproveCleanService.php
@@ -0,0 +1,390 @@
+ 0, 'held_quality' => 0, 'vetoed' => 0, 'skipped' => 0, 'errors' => 0];
+
+ $enabledGroupIds = $this->enabledGroupIds();
+ if ($enabledGroupIds === []) {
+ // Feature dark (default): posts follow the pre-existing behaviour — Pending
+ // until a moderator acts or the 48h AutoApproveService fallback fires.
+ return $stats;
+ }
+
+ // The delay is per-group (settings.autoapprove.delay_minutes) with a site-wide
+ // fallback, so it must be resolved in SQL — a single global threshold would ignore
+ // group overrides. A 0/absent override means "use the site default".
+ $candidates = DB::table('messages_groups as mg')
+ ->join('messages as m', 'm.id', '=', 'mg.msgid')
+ ->join('users as u', 'u.id', '=', 'm.fromuser')
+ ->join('memberships as mem', function ($j) {
+ $j->on('mem.userid', '=', 'm.fromuser')->on('mem.groupid', '=', 'mg.groupid');
+ })
+ ->join('groups as g', 'g.id', '=', 'mg.groupid')
+ ->select('mg.msgid', 'mg.groupid', 'm.fromuser', 'm.spamtype', 'm.subject', DB::raw('m.type as msgtype'))
+ ->where('mg.collection', MessageGroup::COLLECTION_PENDING)
+ ->whereNull('mg.heldby')
+ ->whereNull('m.heldby')
+ ->whereNull('mg.spamreason')
+ ->whereNull('m.spamreason')
+ // Never auto-approve a message that is in the Spam collection on ANY
+ // group (Discourse #9654). Spam-collection messages surface in the
+ // Pending review queue but must be actioned by a human — mirroring
+ // the identical guard in AutoApproveService.
+ ->whereNotExists(function ($q) {
+ $q->select(DB::raw(1))
+ ->from('messages_groups as spam_mg')
+ ->whereColumn('spam_mg.msgid', 'mg.msgid')
+ ->where('spam_mg.collection', MessageGroup::COLLECTION_SPAM)
+ ->where('spam_mg.deleted', 0);
+ })
+ ->where('mg.deleted', 0)
+ ->whereNull('m.deleted')
+ ->whereNull('u.deleted')
+ ->whereNull('mem.ourPostingStatus') // the auto-moderated tier
+ ->whereNotNull('mg.contentcheck_checked_at') // content check has run ...
+ ->whereNull('mg.contentcheck_reasons') // ... and the post was clean
+ ->where('mg.quality_sample', 0) // already-sampled rows are excluded entirely
+ ->where('mg.rippled_in', 0) // rippled-in rows belong to AutoApproveService (carries the Taken/Received + rippled_in_pending_hours + recentLogs-bypass guards)
+ ->whereRaw(
+ "mg.arrival <= (NOW() - INTERVAL COALESCE(NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(g.settings, '$.autoapprove.delay_minutes')) AS UNSIGNED), 0), ?) MINUTE)",
+ [$this->defaultDelayMinutes()]
+ )
+ ->whereRaw(
+ '(mg.autoapprove_hold_until IS NULL OR mg.autoapprove_hold_until <= NOW())'
+ )
+ // Phased trial: while the master switch is off, only the listed groups take part.
+ ->when($enabledGroupIds !== null, function ($q) use ($enabledGroupIds) {
+ $q->whereIn('mg.groupid', $enabledGroupIds);
+ })
+ ->orderBy('mg.msgid')
+ ->orderBy('mg.groupid')
+ // Bound the per-tick batch like the sibling every-minute crons (ripple:expand
+ // etc.): a post-outage backlog drains at 500/minute instead of one huge run
+ // outliving its overlap mutex. Oldest msgids first, so the backlog is FIFO.
+ ->limit(500)
+ ->get();
+
+ foreach ($candidates as $row) {
+ try {
+ if (!$this->groupAllowsAutoApprove((int) $row->groupid)) {
+ $stats['skipped']++;
+ continue;
+ }
+
+ if ($this->hasDangerSignals((int) $row->msgid, (int) $row->groupid, (int) $row->fromuser)) {
+ $stats['vetoed']++;
+ continue;
+ }
+
+ if ($this->isQualitySampled((int) $row->msgid, (int) $row->groupid)) {
+ if (!$dryRun) {
+ // Mark it as a quality-check sample so the moderation-stats
+ // dashboard can compare the mod's verdict on the sample
+ // against the auto-approved population's later error rate.
+ DB::table('messages_groups')
+ ->where('msgid', $row->msgid)
+ ->where('groupid', $row->groupid)
+ ->where('quality_sample', 0)
+ ->update(['quality_sample' => 1]);
+ $stats['held_quality']++;
+ }
+ continue;
+ }
+
+ if ($dryRun) {
+ $stats['approved']++;
+ Log::info("Dry run: would auto-approve clean message #{$row->msgid} on group #{$row->groupid}");
+ continue;
+ }
+
+ $this->approve($row);
+ $stats['approved']++;
+ } catch (\Exception $e) {
+ Log::error("AutoApproveClean: error processing message #{$row->msgid}: " . $e->getMessage());
+ $stats['errors']++;
+ }
+ }
+
+ return $stats;
+ }
+
+ /**
+ * The group must be open, published and NOT moderated — a moderated group (or one
+ * under the Big Switch) deliberately wants every post reviewed by a human.
+ */
+ protected function groupAllowsAutoApprove(int $groupid): bool
+ {
+ $group = Group::find($groupid);
+ if (!$group) {
+ return false;
+ }
+ if (!$group->getSetting('publish', true)) {
+ return false;
+ }
+ if ($group->isClosed()) {
+ return false;
+ }
+ if ($group->getAttribute('autofunctionoverride')) {
+ return false;
+ }
+ if ($group->getAttribute('overridemoderation') === 'ModerateAll') {
+ return false;
+ }
+ if (!empty($group->getSetting('moderated', 0))) {
+ return false;
+ }
+ $rules = $group->rules ?? [];
+ if (!empty($rules['fullymoderated'])) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Any one of these "danger signals" keeps the post in Pending for a moderator to handle.
+ */
+ protected function hasDangerSignals(int $msgid, int $groupid, int $fromuser): bool
+ {
+ // A microvolunteer flagged the post as not OK.
+ if (DB::table('microactions')
+ ->where('msgid', $msgid)
+ ->where('actiontype', 'CheckMessage')
+ ->where('result', 'Reject')
+ ->exists()) {
+ return true;
+ }
+
+ // A moderator has left a note on this member.
+ if (DB::table('users_comments')->where('userid', $fromuser)->exists()) {
+ return true;
+ }
+
+ // A recent negative moderation action against this member (rejection, deletion,
+ // modmail, spam classification) — not a self-initiated action.
+ if (DB::table('logs')
+ ->where('user', $fromuser)
+ ->where('timestamp', '>=', now()->subDays($this->dangerLogDays()))
+ ->where(function ($q) {
+ $q->whereColumn('byuser', '!=', 'user')->orWhereNull('byuser');
+ })
+ ->where(function ($q) {
+ $q->where(function ($q2) {
+ $q2->where('type', 'Message')->whereIn('subtype', self::DANGER_MESSAGE_SUBTYPES);
+ })->orWhere(function ($q2) {
+ $q2->where('type', 'User')->whereIn('subtype', self::DANGER_USER_SUBTYPES);
+ });
+ })
+ ->exists()) {
+ return true;
+ }
+
+ // A known or suspected spammer.
+ if (DB::table('spam_users')
+ ->where('userid', $fromuser)
+ ->whereIn('collection', ['Spammer', 'PendingAdd'])
+ ->exists()) {
+ return true;
+ }
+
+ // A moderation review is outstanding on this membership.
+ if (DB::table('memberships')
+ ->where('userid', $fromuser)
+ ->where('groupid', $groupid)
+ ->whereNotNull('reviewrequestedat')
+ ->where(function ($q) {
+ $q->whereNull('reviewedat')->orWhereColumn('reviewedat', '<', 'reviewrequestedat');
+ })
+ ->exists()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Deterministically hold a configurable percentage of otherwise-eligible posts in
+ * Pending so a moderator spot-checks the auto-approval quality. Deterministic on msgid
+ * so a held message never oscillates between runs.
+ */
+ protected function isQualitySampled(int $msgid, int $groupid): bool
+ {
+ $percent = $this->qualityPercentForGroup($groupid);
+ if ($percent <= 0) {
+ return false;
+ }
+ if ($percent >= 100) {
+ return true;
+ }
+
+ return (abs(crc32((string) $msgid)) % 100) < $percent;
+ }
+
+ protected function qualityPercentForGroup(int $groupid): int
+ {
+ $group = Group::find($groupid);
+ $settings = $group?->settings ?? [];
+ $val = $settings['autoapprove']['quality_check_percent'] ?? null;
+ if ($val === null || $val === '') {
+ return $this->defaultQualityCheckPercent();
+ }
+
+ return (int) $val;
+ }
+
+ /**
+ * Approve a message on a group. Mirrors AutoApproveService::approveOnGroup side effects
+ * (NULL approvedby, reset arrival, Autoapproved log, Ham recording) and additionally
+ * queues a freebie-alert task for Offers, matching the immediate contentcheck approval.
+ */
+ protected function approve(object $row): void
+ {
+ // notSpam parity: whitelist the subject when it was flagged for cross-group reuse.
+ if ($row->spamtype === 'SubjectUsedForDifferentGroups' && $row->subject) {
+ DB::table('spam_whitelist_subjects')->insertOrIgnore([
+ 'subject' => AutoApproveService::getPrunedSubject($row->subject),
+ 'comment' => 'Marked as not spam',
+ ]);
+ }
+
+ // notSpam parity: record HAM when the message had been marked spam.
+ if ($row->spamtype) {
+ DB::table('messages_spamham')->upsert(
+ ['msgid' => $row->msgid, 'spamham' => 'Ham'],
+ ['msgid'],
+ ['spamham']
+ );
+ }
+
+ DB::transaction(function () use ($row) {
+ DB::table('messages_groups')
+ ->where('msgid', $row->msgid)
+ ->where('groupid', $row->groupid)
+ ->where('collection', '!=', MessageGroup::COLLECTION_APPROVED)
+ ->update([
+ 'collection' => MessageGroup::COLLECTION_APPROVED,
+ 'approvedby' => null, // NULL marks an auto-approval
+ 'approvedat' => now(),
+ 'arrival' => now(), // so the digest picks it up as new
+ ]);
+
+ DB::table('logs')->insert([
+ 'timestamp' => now(),
+ 'type' => 'Message',
+ 'subtype' => 'Autoapproved',
+ 'msgid' => $row->msgid,
+ 'groupid' => $row->groupid,
+ 'user' => $row->fromuser,
+ ]);
+
+ if ($row->msgtype === Message::TYPE_OFFER) {
+ DB::table('background_tasks')->insert([
+ 'task_type' => BackgroundTask::TASK_FREEBIE_ALERTS_ADD,
+ 'data' => json_encode(['msgid' => (int) $row->msgid]),
+ ]);
+ }
+
+ // Mirror the Go manual-approve path (addApprovedMessageToSpatialIndex): seed
+ // messages_spatial now so the post appears in browse/search — and becomes
+ // visible to the rippling engine — immediately, instead of waiting up to
+ // 5 minutes for the spatial reconciler cron. Re-checks Approved so it is a
+ // no-op if anything above did not land.
+ DB::statement(
+ "INSERT INTO messages_spatial (msgid, point, groupid, msgtype, arrival)
+ SELECT m.id, ST_GeomFromText(CONCAT('POINT(', m.lng, ' ', m.lat, ')'), 3857),
+ mg.groupid, m.type, mg.arrival
+ FROM messages m
+ INNER JOIN messages_groups mg ON mg.msgid = m.id
+ LEFT JOIN messages_outcomes mo ON mo.msgid = m.id
+ WHERE m.id = ? AND mg.groupid = ? AND mg.collection = 'Approved'
+ AND mg.deleted = 0 AND m.deleted IS NULL
+ AND m.lat IS NOT NULL AND m.lng IS NOT NULL
+ AND NOT (m.lat = 0 AND m.lng = 0)
+ AND mo.id IS NULL
+ ON DUPLICATE KEY UPDATE point = VALUES(point), msgtype = VALUES(msgtype),
+ arrival = VALUES(arrival)",
+ [$row->msgid, $row->groupid]
+ );
+ });
+
+ Log::info("AutoApproveClean: approved message #{$row->msgid} on group #{$row->groupid}");
+ }
+}
diff --git a/iznik-batch/app/Services/AutoApproveService.php b/iznik-batch/app/Services/AutoApproveService.php
index a631b1e209..3c5162ba80 100644
--- a/iznik-batch/app/Services/AutoApproveService.php
+++ b/iznik-batch/app/Services/AutoApproveService.php
@@ -103,6 +103,17 @@ public function process(bool $dryRun = false): array
->whereColumn('messages_outcomes.msgid', 'messages_groups.msgid')
->whereIn('messages_outcomes.outcome', ['Taken', 'Received']);
})
+ // Respect a moderator-set hold window (set by the Go Pending list fetch,
+ // extend-only to NOW()+10m) so a post a mod is actively reviewing is not
+ // auto-approved out from under them.
+ ->whereRaw(
+ '(messages_groups.autoapprove_hold_until IS NULL OR messages_groups.autoapprove_hold_until <= NOW())'
+ )
+ // Posts held back as a manual quality-check sample stay held for a human:
+ // letting the 48h fallback sweep them up would silently drain the sample
+ // AutoApproveCleanService set aside, breaking the sample-vs-population
+ // error-rate comparison on the moderation stats.
+ ->where('messages_groups.quality_sample', 0)
->where(function ($q) {
// Normal posts: the 48h fallback (unchanged).
$q->where(function ($q2) {
@@ -281,10 +292,14 @@ protected function approveOnGroup(object $candidate, int $groupid): void
// V1 approve(): UPDATE messages_groups SET collection='Approved', approvedby=whoAmId(),
// approvedat=NOW(), arrival=NOW() WHERE msgid=? AND groupid=? AND collection!='Approved'
// V1 whoAmId() returns NULL in cron context (no session).
- DB::table('messages_groups')
+ $updated = DB::table('messages_groups')
->where('msgid', $candidate->msgid)
->where('groupid', $groupid)
->where('collection', '!=', MessageGroup::COLLECTION_APPROVED)
+ // Re-check the mod hold at write time: a moderator loading the Pending
+ // queue between the candidate query and this UPDATE bumps
+ // autoapprove_hold_until, and their guaranteed review window must win.
+ ->whereRaw('(autoapprove_hold_until IS NULL OR autoapprove_hold_until <= NOW())')
->update([
'collection' => MessageGroup::COLLECTION_APPROVED,
'approvedby' => null,
@@ -292,6 +307,13 @@ protected function approveOnGroup(object $candidate, int $groupid): void
'arrival' => now(),
]);
+ if ($updated === 0) {
+ // Hold bumped mid-run, or someone else approved it first — either way the
+ // approval did not happen here, so no Autoapproved log (the moderation
+ // stats count those logs as real auto-approvals).
+ return;
+ }
+
// V1 autoapprove() log: type=Message, subtype=Autoapproved.
DB::table('logs')->insert([
'timestamp' => now(),
diff --git a/iznik-batch/app/Services/Ripple/ExpandService.php b/iznik-batch/app/Services/Ripple/ExpandService.php
index be54cbb5c8..ebc36d7ecc 100644
--- a/iznik-batch/app/Services/Ripple/ExpandService.php
+++ b/iznik-batch/app/Services/Ripple/ExpandService.php
@@ -92,7 +92,7 @@ public function process(bool $dryRun = false, int $limit = 500, ?int $onlyMsgid
'initialized' => 0, 'expanded' => 0, 'completed' => 0,
'removed' => 0, 'skipped' => 0, 'errors' => 0, 'rippled_in' => 0, 'mailed' => 0,
'memberships_added' => 0, 'pulled_on_leave' => 0,
- 'pulled_on_removal' => 0, 'memberships_removed' => 0,
+ 'pulled_on_removal' => 0, 'memberships_removed' => 0, 'reach_capped' => 0,
];
// A scoped run ($onlyMsgid or $withinPolyWkt) targets a chosen subset of posts (controlled/area
@@ -698,6 +698,24 @@ private function initialiseNew(bool $dryRun, int $limit, array &$stats, ?int $on
$params[] = $satStop;
}
}
+
+ // 1h HOLD (earned-reach gate; dark unless RIPPLE_EARNED_REACH_ENABLED). An auto-published
+ // post - approvedby IS NULL AND checkedat IS NULL on its origin row - must be at least
+ // RIPPLE_AUTOAPPROVE_HOLD_SECONDS old before it starts rippling, so a mod or the microvol
+ // pool can catch a bad one before it fans out. A post a mod approved (approvedby NOT NULL)
+ // or checked (checkedat NOT NULL) skips the hold; an explicit --msgid run bypasses it,
+ // exactly like the arrival cutoff. The hold param is pushed before $limit so it binds in order.
+ $holdSql = '';
+ if (config('freegle.ripple.earned_reach_enabled', false) && $onlyMsgid === null) {
+ $holdSeconds = (int) config('freegle.ripple.autoapprove_hold_seconds', 3600);
+ if ($holdSeconds > 0) {
+ $holdSql = ' AND (EXISTS (SELECT 1 FROM messages_groups mg_h'
+ . ' WHERE mg_h.msgid = ms.msgid AND (mg_h.approvedby IS NOT NULL OR mg_h.checkedat IS NOT NULL))'
+ . ' OR ms.arrival <= DATE_SUB(NOW(), INTERVAL ? SECOND))';
+ $params[] = $holdSeconds;
+ }
+ }
+
$params[] = $limit;
// Candidate source: live posts with NO reach row yet (anti-join).
@@ -708,7 +726,7 @@ private function initialiseNew(bool $dryRun, int $limit, array &$stats, ?int $on
MIN(ms.arrival) AS arrival
FROM messages_spatial ms
LEFT JOIN rippling_reach mr ON mr.msgid = ms.msgid
- WHERE mr.msgid IS NULL' . $scopeSql . $cutoffSql . $satSql . '
+ WHERE mr.msgid IS NULL' . $scopeSql . $cutoffSql . $satSql . $holdSql . '
GROUP BY ms.msgid
LIMIT ?',
$params
@@ -1117,6 +1135,59 @@ private function reachableGateEnabled(): bool
* (the default - no Pending flicker, since the post was already vetted on origin), else
* Pending so AutoApproveService approves it after the mod-veto window.
*/
+ /**
+ * Earned-reach signals. Reach is earned by clean EXPOSURE, not by active reviewer
+ * sign-off: a per-community microvolunteer/moderator approval requirement could never
+ * keep up with post volume, so it would stall rippling for nearly every post. Instead,
+ * members who have seen the post without anyone flagging it vouch for it passively.
+ */
+
+ /** Distinct members other than the poster who have viewed the post (browse dwell,
+ * expand-in-list and message-page views all record a messages_likes View row). */
+ private function cleanViews(int $msgid, int $fromuser): int
+ {
+ return (int) DB::selectOne(
+ "SELECT COUNT(DISTINCT userid) AS n FROM messages_likes
+ WHERE msgid = ? AND type = 'View' AND userid != ?",
+ [$msgid, $fromuser]
+ )->n;
+ }
+
+ /**
+ * Someone flagged the post: a member wrote to a mod team about it (a User2Mod chat
+ * message referencing the post, which is exactly what the front-end report flow sends),
+ * or a microvolunteer rejected it. Conservative on purpose - a false positive merely
+ * pauses further spread until a moderator looks.
+ */
+ private function isFlagged(int $msgid, int $fromuser): bool
+ {
+ $reported = (int) DB::selectOne(
+ "SELECT COUNT(*) AS n FROM chat_messages cm
+ INNER JOIN chat_rooms cr ON cr.id = cm.chatid AND cr.chattype = 'User2Mod'
+ WHERE cm.refmsgid = ? AND cm.userid != ?",
+ [$msgid, $fromuser]
+ )->n;
+ if ($reported > 0) {
+ return true;
+ }
+
+ return (int) DB::selectOne(
+ "SELECT COUNT(*) AS n FROM microactions WHERE msgid = ? AND result = 'Reject'",
+ [$msgid]
+ )->n > 0;
+ }
+
+ /** A moderator has looked at the post (approved or checked any of its rows) - a human
+ * look supersedes the crowd signals and clears the gate entirely. */
+ private function modLooked(int $msgid): bool
+ {
+ return (int) DB::selectOne(
+ 'SELECT COUNT(*) AS n FROM messages_groups
+ WHERE msgid = ? AND (approvedby IS NOT NULL OR checkedat IS NOT NULL)',
+ [$msgid]
+ )->n > 0;
+ }
+
private function rippleIntoNewGroups(int $msgid, string $reachWkt, array &$stats, ?array $reachableGroupIds = null): void
{
try {
@@ -1230,6 +1301,56 @@ private function rippleIntoNewGroups(int $msgid, string $reachWkt, array &$stats
[$reachWkt, $msgid, $msg->fromuser, $msg->fromuser, $msg->fromuser]
);
+ // EARNED-REACH CAP (dark unless RIPPLE_EARNED_REACH_ENABLED). An auto-published post
+ // (approvedby IS NULL on its origin row) may be rippled into at most N communities
+ // while it has clean_views_per_group distinct clean views per community and nobody
+ // has flagged it. A flag (member report / microvol Reject) pauses spread outright;
+ // a moderator look (approvedby or checkedat anywhere) clears the gate entirely.
+ // While capped: insert nothing, stamp awaiting_review_since, count it, return. When
+ // exposure later catches up (or a mod looks) the wait is banked into
+ // awaiting_review_seconds and the stamp is cleared.
+ if (config('freegle.ripple.earned_reach_enabled', false)) {
+ $originRow = DB::selectOne(
+ 'SELECT approvedby FROM messages_groups WHERE msgid = ? ORDER BY arrival ASC LIMIT 1',
+ [$msgid]
+ );
+ $viewsPerGroup = (int) config('freegle.ripple.clean_views_per_group', 5);
+ if ($originRow !== null && $originRow->approvedby === null
+ && $viewsPerGroup > 0 && !$this->modLooked($msgid)) {
+ $candidateCount = count($targetGroups);
+
+ if ($candidateCount > 0) {
+ $alreadyRippledIn = (int) DB::table('messages_groups')
+ ->where('msgid', $msgid)->where('rippled_in', 1)->where('deleted', 0)->count();
+ $requiredViews = $viewsPerGroup * ($alreadyRippledIn + $candidateCount);
+
+ if ($this->isFlagged($msgid, (int) $msg->fromuser)
+ || $this->cleanViews($msgid, (int) $msg->fromuser) < $requiredViews) {
+ DB::statement(
+ 'UPDATE rippling_reach
+ SET awaiting_review_since = IF(awaiting_review_since IS NULL, NOW(), awaiting_review_since),
+ updated_at = NOW()
+ WHERE msgid = ?',
+ [$msgid]
+ );
+ $stats['reach_capped']++;
+
+ return;
+ }
+
+ // Sufficient exposure: if we were paused, bank the waited time and clear the stamp.
+ DB::statement(
+ 'UPDATE rippling_reach
+ SET awaiting_review_seconds = awaiting_review_seconds
+ + TIMESTAMPDIFF(SECOND, awaiting_review_since, NOW()),
+ awaiting_review_since = NULL, updated_at = NOW()
+ WHERE msgid = ? AND awaiting_review_since IS NOT NULL',
+ [$msgid]
+ );
+ }
+ }
+ }
+
$n = 0;
foreach ($targetGroups as $g) {
$inserted = DB::affectingStatement(
diff --git a/iznik-batch/config/freegle.php b/iznik-batch/config/freegle.php
index e89fd047d7..bf43ec0c20 100644
--- a/iznik-batch/config/freegle.php
+++ b/iznik-batch/config/freegle.php
@@ -357,6 +357,21 @@
// rippling) and per tick (a post that becomes saturated stops fanning out). 0 disables.
// 5 = the figure from the Discourse rippling thread.
'reply_saturation_stop' => (int) env('RIPPLE_REPLY_SATURATION_STOP', 5),
+ // EARNED-REACH GATE (auto-approve x rippling). Ships DARK: when false, ExpandService
+ // behaves exactly as before - no 1h hold, no exposure cap, no await-review
+ // instrumentation. Go-live is a deliberate env flip, exactly like RIPPLE_ENABLED.
+ 'earned_reach_enabled' => (bool) env('RIPPLE_EARNED_REACH_ENABLED', false),
+ // When the gate is on: minimum age (seconds) an auto-published, unreviewed post must
+ // reach before it may start rippling. A post with approvedby or checkedat set (a mod
+ // approved or checked it) skips the hold. Default 3600 (1h); 0 disables just the hold.
+ 'autoapprove_hold_seconds' => (int) env('RIPPLE_AUTOAPPROVE_HOLD_SECONDS', 3600),
+ // When the gate is on: distinct clean VIEWS required per community an auto-published
+ // post ripples into. Reach is earned by exposure without complaint - members seeing
+ // the post and nobody flagging it - not by active reviewer sign-off (a per-community
+ // reviewer requirement could never keep up with post volume). Any flag (member report
+ // to the mods referencing the post, or a microvolunteer Reject) pauses further spread
+ // until a moderator look (approvedby/checkedat) clears it. 0 disables just the cap.
+ 'clean_views_per_group' => (int) env('RIPPLE_CLEAN_VIEWS_PER_GROUP', 5),
// Hours a rippled-in (messages_groups.rippled_in=1) post, already Approved on its
// origin group, waits before it is approved onto the rippled-in group (it was already
// vetted on origin). Default 0 = approve AT ripple-in time, so it never even flickers
@@ -601,6 +616,39 @@
'api_key' => env('FREEBIE_ALERTS_KEY', ''),
],
+ /*
+ |--------------------------------------------------------------------------
+ | Auto-approve (delayed) for NULL-status ("auto-moderated") members
+ |--------------------------------------------------------------------------
+ |
+ | Site-wide defaults for messages:auto-approve-clean. A group may override
+ | delay_minutes / quality_check_percent via settings.autoapprove.* (an
+ | absent or 0 delay override falls back to the site default below).
+ |
+ | - enabled: master switch for the clean-path auto-approve.
+ | DEFAULT OFF: merging/deploying this code changes
+ | nothing until the switch is deliberately flipped.
+ | - trial_group_ids: comma-separated group ids for a phased trial while
+ | the master switch is off (mirrors the rippling
+ | group experiment: RIPPLE_WITHIN_GROUPS with
+ | RIPPLE_ENABLED false). Ignored when enabled=true.
+ | - delay_minutes: how long a content-check-clean post stays in
+ | Pending before auto-approval, giving mods and
+ | microvolunteers a chance to intervene.
+ | - quality_check_percent: percentage of otherwise-eligible posts held back
+ | in Pending for a manual mod quality check (0 = none).
+ | - danger_log_days: how far back to look for negative moderation log
+ | entries that veto auto-approval.
+ |
+ */
+ 'autoapprove' => [
+ 'enabled' => (bool) env('FREEGLE_AUTOAPPROVE_ENABLED', false),
+ 'trial_group_ids' => env('FREEGLE_AUTOAPPROVE_TRIAL_GROUPS', ''),
+ 'delay_minutes' => (int) env('FREEGLE_AUTOAPPROVE_DELAY_MINUTES', 20),
+ 'quality_check_percent' => (int) env('FREEGLE_AUTOAPPROVE_QUALITY_CHECK_PCT', 0),
+ 'danger_log_days' => (int) env('FREEGLE_AUTOAPPROVE_DANGER_LOG_DAYS', 90),
+ ],
+
'email_health' => [
'incoming_window_hours' => env('FREEGLE_EMAIL_HEALTH_INCOMING_WINDOW_HOURS', 2),
'outgoing_min_per_hour' => env('FREEGLE_EMAIL_HEALTH_OUTGOING_MIN_PER_HOUR', 10),
diff --git a/iznik-batch/database/migrations/2026_06_04_000001_add_checked_columns_to_messages_groups.php b/iznik-batch/database/migrations/2026_06_04_000001_add_checked_columns_to_messages_groups.php
new file mode 100644
index 0000000000..5ffd009837
--- /dev/null
+++ b/iznik-batch/database/migrations/2026_06_04_000001_add_checked_columns_to_messages_groups.php
@@ -0,0 +1,39 @@
+timestamp('checkedat')->nullable()->after('rejectedat');
+ $table->unsignedBigInteger('checkedby')->nullable()->after('checkedat');
+
+ $table->foreign('checkedby')->references('id')->on('users')->onDelete('set null');
+ // Composite index supports the "unchecked in this group" oversight query.
+ $table->index(['groupid', 'checkedat'], 'messages_groups_groupid_checkedat_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages_groups', function (Blueprint $table) {
+ $table->dropForeign(['checkedby']);
+ $table->dropIndex('messages_groups_groupid_checkedat_idx');
+ $table->dropColumn(['checkedat', 'checkedby']);
+ });
+ }
+};
diff --git a/iznik-batch/database/migrations/2026_06_04_000002_add_quality_sample_to_messages_groups.php b/iznik-batch/database/migrations/2026_06_04_000002_add_quality_sample_to_messages_groups.php
new file mode 100644
index 0000000000..b20c1fbbde
--- /dev/null
+++ b/iznik-batch/database/migrations/2026_06_04_000002_add_quality_sample_to_messages_groups.php
@@ -0,0 +1,33 @@
+boolean('quality_sample')->default(false)->after('checkedby');
+ $table->index(['groupid', 'quality_sample'], 'messages_groups_groupid_qsample_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages_groups', function (Blueprint $table) {
+ $table->dropIndex('messages_groups_groupid_qsample_idx');
+ $table->dropColumn('quality_sample');
+ });
+ }
+};
diff --git a/iznik-batch/database/migrations/2026_06_15_000001_add_autoapprove_hold_until_to_messages_groups.php b/iznik-batch/database/migrations/2026_06_15_000001_add_autoapprove_hold_until_to_messages_groups.php
new file mode 100644
index 0000000000..89d0360b41
--- /dev/null
+++ b/iznik-batch/database/migrations/2026_06_15_000001_add_autoapprove_hold_until_to_messages_groups.php
@@ -0,0 +1,38 @@
+timestamp('autoapprove_hold_until')->nullable()->after('quality_sample');
+ // Keeps the candidate-query predicate fast: both auto-approvers gain
+ // AND (mg.autoapprove_hold_until IS NULL OR mg.autoapprove_hold_until <= NOW())
+ $table->index(['groupid', 'autoapprove_hold_until'], 'messages_groups_groupid_hold_until_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages_groups', function (Blueprint $table) {
+ $table->dropIndex('messages_groups_groupid_hold_until_idx');
+ $table->dropColumn('autoapprove_hold_until');
+ });
+ }
+};
diff --git a/iznik-batch/database/migrations/2026_06_25_000001_add_await_review_to_rippling_reach.php b/iznik-batch/database/migrations/2026_06_25_000001_add_await_review_to_rippling_reach.php
new file mode 100644
index 0000000000..7090e02589
--- /dev/null
+++ b/iznik-batch/database/migrations/2026_06_25_000001_add_await_review_to_rippling_reach.php
@@ -0,0 +1,44 @@
+sendOutputTo(cronLog('ripple:proximity-notes'))
->runInBackground();
+// Auto-approve content-check-clean posts from NULL-status ("auto-moderated") members
+// after the configured delay (default 20 min), unless danger signals are present and
+// excluding a quality-check sample. Runs every minute so the window is honoured tightly.
+// Dark by default: FREEGLE_AUTOAPPROVE_ENABLED / FREEGLE_AUTOAPPROVE_TRIAL_GROUPS gate it
+// inside the service. Overlap expiry bounded (minutes) so a killed run can't leave a
+// mutex behind that stalls auto-approval for the default 24h.
+Schedule::command('messages:auto-approve-clean')
+ ->everyMinute()
+ ->withoutOverlapping(15)
+ ->sendOutputTo(cronLog('messages:auto-approve-clean'))
+ ->runInBackground();
+
// Update UK spatial data - runs monthly.
// Downloads UK OSM PBF file and rebuilds deprivation quintile CSV for spatial server.
// Signals Go spatial server to reload after update.
diff --git a/iznik-batch/tests/Unit/Commands/Message/AutoApproveCleanCommandTest.php b/iznik-batch/tests/Unit/Commands/Message/AutoApproveCleanCommandTest.php
new file mode 100644
index 0000000000..07ac8314b2
--- /dev/null
+++ b/iznik-batch/tests/Unit/Commands/Message/AutoApproveCleanCommandTest.php
@@ -0,0 +1,69 @@
+ 0, 'held_quality' => 0, 'vetoed' => 0, 'skipped' => 0, 'errors' => 0,
+ ], $overrides);
+ }
+
+ public function test_success_with_no_errors(): void
+ {
+ $service = $this->createMock(AutoApproveCleanService::class);
+ $service->method('process')->willReturn($this->stats([
+ 'approved' => 5, 'held_quality' => 1, 'vetoed' => 2, 'skipped' => 3,
+ ]));
+ $this->app->instance(AutoApproveCleanService::class, $service);
+
+ $this->artisan('messages:auto-approve-clean')
+ ->expectsOutputToContain('Approved: 5, Held (quality): 1, Vetoed: 2, Skipped: 3, Errors: 0')
+ ->assertExitCode(Command::SUCCESS);
+ }
+
+ public function test_failure_when_errors_present(): void
+ {
+ $service = $this->createMock(AutoApproveCleanService::class);
+ $service->method('process')->willReturn($this->stats(['approved' => 1, 'errors' => 3]));
+ $this->app->instance(AutoApproveCleanService::class, $service);
+
+ $this->artisan('messages:auto-approve-clean')
+ ->expectsOutputToContain('Errors: 3')
+ ->assertExitCode(Command::FAILURE);
+ }
+
+ public function test_dry_run_announces_mode_and_passes_flag(): void
+ {
+ $service = $this->createMock(AutoApproveCleanService::class);
+ $service->expects($this->once())
+ ->method('process')
+ ->with(true)
+ ->willReturn($this->stats());
+ $this->app->instance(AutoApproveCleanService::class, $service);
+
+ $this->artisan('messages:auto-approve-clean', ['--dry-run' => true])
+ ->expectsOutputToContain('no changes will be made')
+ ->expectsOutputToContain('[DRY RUN] Approved: 0')
+ ->assertExitCode(Command::SUCCESS);
+ }
+
+ public function test_passes_false_when_not_dry_run(): void
+ {
+ $service = $this->createMock(AutoApproveCleanService::class);
+ $service->expects($this->once())
+ ->method('process')
+ ->with(false)
+ ->willReturn($this->stats());
+ $this->app->instance(AutoApproveCleanService::class, $service);
+
+ $this->artisan('messages:auto-approve-clean')
+ ->assertExitCode(Command::SUCCESS);
+ }
+}
diff --git a/iznik-batch/tests/Unit/Services/AutoApproveCleanServiceTest.php b/iznik-batch/tests/Unit/Services/AutoApproveCleanServiceTest.php
new file mode 100644
index 0000000000..e78d3bc502
--- /dev/null
+++ b/iznik-batch/tests/Unit/Services/AutoApproveCleanServiceTest.php
@@ -0,0 +1,621 @@
+service = new AutoApproveCleanService();
+
+ // Deterministic site defaults for the tests. The rollout gate defaults OFF in
+ // config; these tests exercise the behaviour WHEN ENABLED, so switch it on here.
+ // The gate itself is covered by the test_rollout_gate_* tests below.
+ config([
+ 'freegle.autoapprove.enabled' => true,
+ 'freegle.autoapprove.trial_group_ids' => '',
+ 'freegle.autoapprove.delay_minutes' => 20,
+ 'freegle.autoapprove.quality_check_percent' => 0,
+ 'freegle.autoapprove.danger_log_days' => 90,
+ ]);
+ }
+
+ /**
+ * Build a clean, content-checked, NULL-status pending post that is eligible for
+ * auto-approval (arrival older than the default 20-minute delay).
+ *
+ * @return array{0: User, 1: Group, 2: Message}
+ */
+ private function makeApprovable(array $opts = []): array
+ {
+ $user = $this->createTestUser();
+ $group = $this->createTestGroup($opts['group'] ?? []);
+ $this->createMembership($user, $group, array_merge([
+ 'added' => now()->subDays(2),
+ // ourPostingStatus left NULL — the auto-moderated tier.
+ ], $opts['membership'] ?? []));
+
+ $message = $this->createTestMessage($user, $group, $opts['message'] ?? []);
+
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(array_merge([
+ 'collection' => MessageGroup::COLLECTION_PENDING,
+ 'arrival' => now()->subMinutes(21),
+ 'contentcheck_checked_at' => now()->subMinutes(20),
+ 'contentcheck_reasons' => null,
+ ], $opts['mg'] ?? []));
+
+ return [$user, $group, $message];
+ }
+
+ private function assertApproved(int $msgid, int $groupid): void
+ {
+ $mg = DB::table('messages_groups')->where('msgid', $msgid)->where('groupid', $groupid)->first();
+ $this->assertEquals(MessageGroup::COLLECTION_APPROVED, $mg->collection, 'message should be Approved');
+ $this->assertNull($mg->approvedby, 'auto-approval leaves approvedby NULL');
+ $this->assertDatabaseHas('logs', [
+ 'msgid' => $msgid, 'groupid' => $groupid, 'type' => 'Message', 'subtype' => 'Autoapproved',
+ ]);
+ }
+
+ private function assertStillPending(int $msgid, int $groupid): void
+ {
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $msgid, 'groupid' => $groupid, 'collection' => MessageGroup::COLLECTION_PENDING,
+ ]);
+ $this->assertDatabaseMissing('logs', [
+ 'msgid' => $msgid, 'type' => 'Message', 'subtype' => 'Autoapproved',
+ ]);
+ }
+
+ public function test_stats_structure(): void
+ {
+ $stats = $this->service->process();
+ foreach (['approved', 'held_quality', 'vetoed', 'skipped', 'errors'] as $key) {
+ $this->assertArrayHasKey($key, $stats);
+ }
+ }
+
+ public function test_approves_clean_null_status_post_after_delay(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['approved']);
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ public function test_does_not_approve_before_delay(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'mg' => ['arrival' => now()->subMinutes(19)],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_default_posting_status(): void
+ {
+ // DEFAULT members are trusted and approved immediately by contentcheck — not our job.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'membership' => ['ourPostingStatus' => 'DEFAULT'],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_moderated_posting_status(): void
+ {
+ // Explicit MODERATED stays moderated.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'membership' => ['ourPostingStatus' => 'MODERATED'],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_when_content_check_not_run(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'mg' => ['contentcheck_checked_at' => null],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_when_content_check_flagged_reasons(): void
+ {
+ // Suspect content (reasons present) must keep the post in Pending for a mod.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'mg' => ['contentcheck_reasons' => json_encode([['check' => 'Money', 'action' => 'flag']])],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_held_message(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['heldby' => $user->id]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_message_with_spamreason(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages')->where('id', $message->id)->update(['spamreason' => 'CountryBlocked']);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_soft_deleted_message(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages')->where('id', $message->id)->update(['deleted' => now()]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_moderated_group(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['moderated' => 1]],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_skips_closed_group(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['closed' => true]],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_veto_microvolunteering_reject(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ $reviewer = $this->createTestUser();
+ DB::table('microactions')->insert([
+ 'userid' => $reviewer->id,
+ 'msgid' => $message->id,
+ 'actiontype' => 'CheckMessage',
+ 'result' => 'Reject',
+ 'score_negative' => 1,
+ ]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['vetoed']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_does_not_veto_microvolunteering_approve(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ $reviewer = $this->createTestUser();
+ DB::table('microactions')->insert([
+ 'userid' => $reviewer->id,
+ 'msgid' => $message->id,
+ 'actiontype' => 'CheckMessage',
+ 'result' => 'Approve',
+ 'score_negative' => 0,
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ public function test_veto_user_note(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('users_comments')->insert([
+ 'userid' => $user->id,
+ 'groupid' => $group->id,
+ 'user1' => 'Keep an eye on this member.',
+ ]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['vetoed']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_veto_recent_negative_mod_log(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ $mod = $this->createTestUser();
+ DB::table('logs')->insert([
+ 'timestamp' => now()->subDays(1),
+ 'type' => 'User',
+ 'subtype' => 'Mailed',
+ 'user' => $user->id,
+ 'byuser' => $mod->id,
+ 'groupid' => $group->id,
+ ]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['vetoed']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_does_not_veto_old_negative_log(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ $mod = $this->createTestUser();
+ DB::table('logs')->insert([
+ 'timestamp' => now()->subDays(120), // outside the 90-day danger window
+ 'type' => 'User',
+ 'subtype' => 'Mailed',
+ 'user' => $user->id,
+ 'byuser' => $mod->id,
+ 'groupid' => $group->id,
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ public function test_veto_known_spammer(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('spam_users')->insert([
+ 'userid' => $user->id,
+ 'collection' => 'Spammer',
+ ]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['vetoed']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_veto_membership_review_pending(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('memberships')
+ ->where('userid', $user->id)
+ ->where('groupid', $group->id)
+ ->update(['reviewrequestedat' => now()->subHour(), 'reviewedat' => null]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['vetoed']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_quality_sample_100_holds_all(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['quality_check_percent' => 100]]],
+ ]);
+
+ $stats = $this->service->process();
+
+ $this->assertGreaterThanOrEqual(1, $stats['held_quality']);
+ $this->assertStillPending($message->id, $group->id);
+ // Held posts are marked as a quality-check sample for the stats dashboard.
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'quality_sample' => 1,
+ ]);
+ }
+
+ public function test_quality_sample_zero_holds_none(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['quality_check_percent' => 0]]],
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ public function test_per_group_delay_override(): void
+ {
+ // Site default is 20 min; this group overrides to 5 min, so a 6-min-old post approves.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['delay_minutes' => 5]]],
+ 'mg' => ['arrival' => now()->subMinutes(6)],
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ public function test_zero_group_delay_falls_back_to_site_default(): void
+ {
+ // A 0 override means "use the site default" (20), so a 6-min-old post stays pending.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['delay_minutes' => 0]]],
+ 'mg' => ['arrival' => now()->subMinutes(6)],
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_dry_run_does_not_modify_database(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+
+ $stats = $this->service->process(dryRun: true);
+
+ $this->assertGreaterThanOrEqual(1, $stats['approved']);
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_records_ham_for_spam_message(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages')->where('id', $message->id)->update(['spamtype' => 'SubjectUsedForDifferentGroups']);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ $this->assertDatabaseHas('messages_spamham', ['msgid' => $message->id, 'spamham' => 'Ham']);
+ }
+
+ // --- A2: autoapprove_hold_until predicate ---
+
+ public function test_hold_until_future_keeps_post_pending(): void
+ {
+ // A post whose autoapprove_hold_until is 5 minutes in the future must
+ // be skipped by the auto-approver even though the delay has elapsed.
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['autoapprove_hold_until' => now()->addMinutes(5)]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_hold_until_past_allows_approval(): void
+ {
+ // A post whose autoapprove_hold_until has already expired must still
+ // be auto-approved normally.
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['autoapprove_hold_until' => now()->subMinutes(1)]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+
+ // --- D1: cross-group Spam-collection guard ---
+
+ public function test_spam_on_another_group_blocks_approval_on_this_group(): void
+ {
+ // Message is clean and pending on group A (eligible for auto-approval),
+ // BUT also in the Spam collection on group B.
+ // AutoApproveCleanService must NOT auto-approve it on group A.
+ [$user, $groupA, $message] = $this->makeApprovable();
+
+ $groupB = $this->createTestGroup();
+ // Insert a Spam-collection row for the same message on group B.
+ DB::table('messages_groups')->insert([
+ 'msgid' => $message->id,
+ 'groupid' => $groupB->id,
+ 'collection' => MessageGroup::COLLECTION_SPAM,
+ 'arrival' => now(),
+ 'deleted' => 0,
+ ]);
+
+ $this->service->process();
+
+ $this->assertStillPending($message->id, $groupA->id);
+ }
+
+ // --- D3: quality-sample filter + dry-run stat placement ---
+
+ public function test_already_quality_sampled_row_is_excluded_from_candidate_query(): void
+ {
+ // A row already marked quality_sample=1 must not appear in the candidate
+ // query at all. This verifies the ->where('mg.quality_sample', 0) guard.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['quality_check_percent' => 0]]],
+ ]);
+ // Manually pre-mark the row as already sampled (e.g. by a previous run).
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['quality_sample' => 1]);
+
+ $stats = $this->service->process();
+
+ // The row is excluded from the candidate set entirely — held_quality is 0
+ // because the service never even sees it.
+ $this->assertEquals(0, $stats['held_quality'], 'already-sampled row must not increment held_quality again');
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ public function test_held_quality_stat_not_incremented_in_dry_run(): void
+ {
+ // $stats['held_quality']++ must be inside the if(!$dryRun) block so a
+ // dry-run pass doesn't falsely inflate the cron stat.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'group' => ['settings' => ['autoapprove' => ['quality_check_percent' => 100]]],
+ ]);
+
+ $stats = $this->service->process(dryRun: true);
+
+ $this->assertEquals(0, $stats['held_quality'], 'held_quality must be 0 in a dry run');
+ $this->assertStillPending($message->id, $group->id);
+ }
+
+ // --- D6: OFFER freebie-alert background task ---
+
+ public function test_approved_offer_queues_freebie_alert_background_task(): void
+ {
+ // An approved OFFER must insert a background_tasks row with
+ // task_type=TASK_FREEBIE_ALERTS_ADD containing the msgid, so the Go
+ // background-task processor can fan out push notifications to nearby users.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'message' => ['type' => \App\Models\Message::TYPE_OFFER],
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ $this->assertDatabaseHas('background_tasks', [
+ 'task_type' => \App\Models\BackgroundTask::TASK_FREEBIE_ALERTS_ADD,
+ ]);
+ // Verify the data payload contains the correct msgid.
+ $task = DB::table('background_tasks')
+ ->where('task_type', \App\Models\BackgroundTask::TASK_FREEBIE_ALERTS_ADD)
+ ->first();
+ $data = json_decode($task->data, true);
+ $this->assertEquals((int) $message->id, $data['msgid']);
+ }
+
+ public function test_approved_wanted_does_not_queue_freebie_alert(): void
+ {
+ // Only OFFERs trigger freebie alerts; WANTEDs must not insert a row.
+ [$user, $group, $message] = $this->makeApprovable([
+ 'message' => ['type' => \App\Models\Message::TYPE_WANTED],
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ $this->assertDatabaseMissing('background_tasks', [
+ 'task_type' => \App\Models\BackgroundTask::TASK_FREEBIE_ALERTS_ADD,
+ ]);
+ }
+
+ public function test_approval_seeds_spatial_index_immediately(): void
+ {
+ // Parity with the Go manual-approve path: the post must hit messages_spatial at
+ // approval time (browse/search/rippling visibility), not after the 5-min cron.
+ [$user, $group, $message] = $this->makeApprovable();
+ DB::table('messages_spatial')->where('msgid', $message->id)->delete();
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ $this->assertSame(
+ 1,
+ DB::table('messages_spatial')->where('msgid', $message->id)->where('groupid', $group->id)->count(),
+ 'auto-approved post is in messages_spatial immediately'
+ );
+ }
+
+ // ───────────────────────── Rollout gate (dark by default) ─────────────────────────
+
+ public function test_rollout_gate_off_by_default_approves_nothing(): void
+ {
+ // The config default (enabled=false, no trial groups) must make deploying this
+ // code a no-op: an otherwise-eligible post stays in Pending untouched.
+ config(['freegle.autoapprove.enabled' => false, 'freegle.autoapprove.trial_group_ids' => '']);
+ [$user, $group, $message] = $this->makeApprovable();
+
+ $stats = $this->service->process();
+
+ $this->assertStillPending($message->id, $group->id);
+ $this->assertSame(0, array_sum($stats), 'feature dark: nothing approved, held, vetoed or skipped');
+ }
+
+ public function test_rollout_gate_trial_groups_enable_only_those_groups(): void
+ {
+ // Phased trial: with the master switch off, only posts on the listed groups
+ // auto-approve; identical posts on other groups are untouched.
+ [$userA, $groupA, $messageA] = $this->makeApprovable();
+ [$userB, $groupB, $messageB] = $this->makeApprovable();
+ config([
+ 'freegle.autoapprove.enabled' => false,
+ 'freegle.autoapprove.trial_group_ids' => (string) $groupA->id,
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($messageA->id, $groupA->id);
+ $this->assertStillPending($messageB->id, $groupB->id);
+ }
+
+ public function test_rollout_gate_trial_list_tolerates_spaces_and_multiple_ids(): void
+ {
+ [$userA, $groupA, $messageA] = $this->makeApprovable();
+ [$userB, $groupB, $messageB] = $this->makeApprovable();
+ config([
+ 'freegle.autoapprove.enabled' => false,
+ 'freegle.autoapprove.trial_group_ids' => ' ' . $groupA->id . ' , ' . $groupB->id . ' ',
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($messageA->id, $groupA->id);
+ $this->assertApproved($messageB->id, $groupB->id);
+ }
+
+ public function test_rollout_gate_enabled_ignores_trial_list(): void
+ {
+ // enabled=true is the master switch: the trial list no longer restricts anything.
+ [$user, $group, $message] = $this->makeApprovable();
+ config([
+ 'freegle.autoapprove.enabled' => true,
+ 'freegle.autoapprove.trial_group_ids' => '999999999',
+ ]);
+
+ $this->service->process();
+
+ $this->assertApproved($message->id, $group->id);
+ }
+}
diff --git a/iznik-batch/tests/Unit/Services/AutoApproveHoldUntilMigrationTest.php b/iznik-batch/tests/Unit/Services/AutoApproveHoldUntilMigrationTest.php
new file mode 100644
index 0000000000..14b5a4ade0
--- /dev/null
+++ b/iznik-batch/tests/Unit/Services/AutoApproveHoldUntilMigrationTest.php
@@ -0,0 +1,26 @@
+assertTrue(
+ Schema::hasColumn('messages_groups', 'autoapprove_hold_until'),
+ 'messages_groups.autoapprove_hold_until column must exist after migration'
+ );
+ }
+
+ public function test_autoapprove_hold_until_index_exists(): void
+ {
+ $indexes = collect(
+ DB::select("SHOW INDEX FROM messages_groups WHERE Key_name = 'messages_groups_groupid_hold_until_idx'")
+ );
+ $this->assertNotEmpty($indexes, 'messages_groups_groupid_hold_until_idx index must exist');
+ }
+}
diff --git a/iznik-batch/tests/Unit/Services/AutoApproveServiceHoldTest.php b/iznik-batch/tests/Unit/Services/AutoApproveServiceHoldTest.php
new file mode 100644
index 0000000000..fe5c15eccf
--- /dev/null
+++ b/iznik-batch/tests/Unit/Services/AutoApproveServiceHoldTest.php
@@ -0,0 +1,133 @@
+service = new AutoApproveService();
+ }
+
+ private function makeApprovable48h(): array
+ {
+ $user = $this->createTestUser();
+ $group = $this->createTestGroup();
+ $this->createMembership($user, $group, ['added' => now()->subDays(5)]);
+ $message = $this->createTestMessage($user, $group);
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update([
+ 'collection' => MessageGroup::COLLECTION_PENDING,
+ 'arrival' => now()->subHours(50),
+ ]);
+
+ return [$user, $group, $message];
+ }
+
+ public function test_48h_hold_until_future_keeps_post_pending(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable48h();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['autoapprove_hold_until' => now()->addMinutes(10)]);
+
+ $this->service->process();
+
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'collection' => MessageGroup::COLLECTION_PENDING,
+ ]);
+ }
+
+ public function test_48h_hold_until_past_allows_approval(): void
+ {
+ [$user, $group, $message] = $this->makeApprovable48h();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['autoapprove_hold_until' => now()->subMinutes(1)]);
+
+ $this->service->process();
+
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'collection' => MessageGroup::COLLECTION_APPROVED,
+ ]);
+ }
+
+ public function test_48h_fallback_leaves_quality_sample_for_a_human(): void
+ {
+ // A post AutoApproveCleanService held back as a quality-check sample is waiting
+ // for a moderator's verdict. The 48h fallback must not sweep it up — that would
+ // silently drain the sample and break the sample-vs-population error comparison.
+ [$user, $group, $message] = $this->makeApprovable48h();
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['quality_sample' => 1]);
+
+ $this->service->process();
+
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'collection' => MessageGroup::COLLECTION_PENDING,
+ ]);
+ $this->assertDatabaseMissing('logs', [
+ 'msgid' => $message->id,
+ 'type' => 'Message',
+ 'subtype' => 'Autoapproved',
+ ]);
+ }
+
+ public function test_48h_hold_rechecked_at_write_time(): void
+ {
+ // The candidate query and the UPDATE both check the hold, so a hold bumped
+ // between them (mod opens the Pending page mid-run) wins. Simulate the write-time
+ // side directly: candidate eligible, but the row's hold is bumped before
+ // approveOnGroup's UPDATE executes — the UPDATE must not fire, and crucially no
+ // Autoapproved log may be written for an approval that did not happen.
+ [$user, $group, $message] = $this->makeApprovable48h();
+
+ $candidate = (object) [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'fromuser' => $user->id,
+ 'spamtype' => null,
+ 'subject' => $message->subject,
+ ];
+
+ DB::table('messages_groups')
+ ->where('msgid', $message->id)
+ ->where('groupid', $group->id)
+ ->update(['autoapprove_hold_until' => now()->addMinutes(10)]);
+
+ $method = new \ReflectionMethod($this->service, 'approveOnGroup');
+ $method->setAccessible(true);
+ $method->invoke($this->service, $candidate, $group->id);
+
+ $this->assertDatabaseHas('messages_groups', [
+ 'msgid' => $message->id,
+ 'groupid' => $group->id,
+ 'collection' => MessageGroup::COLLECTION_PENDING,
+ ]);
+ $this->assertDatabaseMissing('logs', [
+ 'msgid' => $message->id,
+ 'type' => 'Message',
+ 'subtype' => 'Autoapproved',
+ ]);
+ }
+}
diff --git a/iznik-batch/tests/Unit/Services/Ripple/ExpandServiceTest.php b/iznik-batch/tests/Unit/Services/Ripple/ExpandServiceTest.php
index af62c102f6..0268cde954 100644
--- a/iznik-batch/tests/Unit/Services/Ripple/ExpandServiceTest.php
+++ b/iznik-batch/tests/Unit/Services/Ripple/ExpandServiceTest.php
@@ -3078,4 +3078,300 @@ public function test_advance_bounds_respect_secondary_reject_clip(): void
)->c;
$this->assertSame(0, $innerAccepts, 'inner bound must not cover the clipped-out rejected area');
}
+
+ // ───────────────────────── Earned-reach gate (dark unless RIPPLE_EARNED_REACH_ENABLED) ─────
+
+ /** Record $n distinct members (not the poster) viewing the post. */
+ private function addCleanViews(int $msgid, int $n): void
+ {
+ for ($i = 0; $i < $n; $i++) {
+ DB::table('messages_likes')->insert([
+ 'msgid' => $msgid, 'userid' => $this->createTestUser()->id,
+ 'type' => 'View', 'timestamp' => now(),
+ ]);
+ }
+ }
+
+ /** A member reports the post to the mods - a User2Mod chat message referencing it,
+ * exactly what the front-end report flow sends. */
+ private function reportToMods(int $msgid): void
+ {
+ $reporter = $this->createTestUser();
+ $groupid = (int) DB::table('messages_groups')->where('msgid', $msgid)->value('groupid');
+ $room = \App\Models\ChatRoom::create([
+ 'chattype' => \App\Models\ChatRoom::TYPE_USER2MOD,
+ 'user1' => $reporter->id, 'groupid' => $groupid, 'created' => now(),
+ ]);
+ DB::table('chat_messages')->insert([
+ 'chatid' => $room->id, 'userid' => $reporter->id, 'refmsgid' => $msgid,
+ 'message' => "I'm reporting this post as inappropriate", 'type' => 'Default',
+ 'date' => now(), 'reviewrequired' => 0, 'processingrequired' => 0,
+ 'processingsuccessful' => 1, 'mailedtoall' => 0, 'seenbyall' => 0,
+ 'reviewrejected' => 0, 'platform' => 1,
+ ]);
+ }
+
+ private function addMicrovolReject(int $msgid): void
+ {
+ DB::table('microactions')->insert([
+ 'actiontype' => 'CheckMessage', 'userid' => $this->createTestUser()->id,
+ 'msgid' => $msgid, 'result' => 'Reject', 'timestamp' => now(),
+ ]);
+ }
+
+ public function test_autoapprove_hold_prevents_young_unreviewed_post_from_rippling(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 3600]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(30)); // younger than the 1h hold
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(0, $stats['initialized'], 'auto-published post within the hold window must not start rippling');
+ $this->assertSame(0, DB::table('rippling_reach')->where('msgid', $msgid)->count(), 'no reach row while held');
+ }
+
+ public function test_autoapprove_hold_allows_old_enough_unreviewed_post(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 3600]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90)); // past the 1h hold
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['initialized'], 'post past the hold ripples normally');
+ $this->assertSame(1, DB::table('rippling_reach')->where('msgid', $msgid)->count());
+ }
+
+ public function test_autoapprove_hold_skipped_when_approvedby_is_set(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 3600]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(5));
+ $modId = (int) DB::table('messages')->where('id', $msgid)->value('fromuser');
+ DB::table('messages_groups')->where('msgid', $msgid)->update(['approvedby' => $modId]);
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['initialized'], 'a mod-approved post bypasses the hold');
+ }
+
+ public function test_autoapprove_hold_skipped_when_checkedat_is_set(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 3600]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(5));
+ DB::table('messages_groups')->where('msgid', $msgid)->update(['checkedat' => now()]);
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['initialized'], 'a mod-checked post bypasses the hold');
+ }
+
+ public function test_autoapprove_hold_bypassed_for_only_msgid_run(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 3600]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(5)); // under the hold
+
+ $stats = $this->service()->process(false, 500, $msgid);
+
+ $this->assertSame(1, $stats['initialized'], '--msgid bypasses the hold, like the arrival cutoff');
+ }
+
+ public function test_clean_views_counts_distinct_viewers_excluding_poster(): void
+ {
+ $svc = $this->service();
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $poster = (int) DB::table('messages')->where('id', $msgid)->value('fromuser');
+ $ref = new \ReflectionMethod($svc, 'cleanViews');
+ $ref->setAccessible(true);
+
+ $this->assertSame(0, $ref->invoke($svc, $msgid, $poster), 'baseline 0 views');
+ $this->addCleanViews($msgid, 2);
+ $this->assertSame(2, $ref->invoke($svc, $msgid, $poster), 'two distinct viewers');
+ DB::table('messages_likes')->insert([
+ 'msgid' => $msgid, 'userid' => $poster, 'type' => 'View', 'timestamp' => now(),
+ ]);
+ $this->assertSame(2, $ref->invoke($svc, $msgid, $poster), "the poster's own view does not count");
+ }
+
+ public function test_earned_reach_cap_blocks_below_view_threshold(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $groupB = $this->seedCoveringGroup();
+ $this->addCleanViews($msgid, 1); // 1 view < required 2 for N=1
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['initialized'], 'reach is still initialised');
+ $this->assertSame(0, $stats['rippled_in'], 'not rippled in while views < required');
+ $this->assertSame(1, $stats['reach_capped']);
+ $this->assertNull(DB::table('messages_groups')->where('msgid', $msgid)->where('groupid', $groupB)->first());
+ $this->assertNotNull(
+ DB::table('rippling_reach')->where('msgid', $msgid)->value('awaiting_review_since'),
+ 'awaiting_review_since stamped on pause'
+ );
+ }
+
+ public function test_earned_reach_cap_passes_with_enough_clean_views(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $groupB = $this->seedCoveringGroup();
+ $this->addCleanViews($msgid, 2); // 2 views >= required 2
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['rippled_in'], '2 clean views meet N=1; ripples in');
+ $this->assertSame(0, $stats['reach_capped']);
+ }
+
+ public function test_earned_reach_cap_pauses_when_flagged_despite_views(): void
+ {
+ // A member report to the mods outweighs any number of quiet views: spread pauses
+ // until a moderator look clears it.
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $this->seedCoveringGroup();
+ $this->addCleanViews($msgid, 10);
+ $this->reportToMods($msgid);
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(0, $stats['rippled_in'], 'a reported post must not spread');
+ $this->assertSame(1, $stats['reach_capped']);
+ }
+
+ public function test_earned_reach_cap_pauses_on_microvol_reject_despite_views(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $this->seedCoveringGroup();
+ $this->addCleanViews($msgid, 10);
+ $this->addMicrovolReject($msgid);
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(0, $stats['rippled_in'], 'a microvol-rejected post must not spread');
+ $this->assertSame(1, $stats['reach_capped']);
+ }
+
+ public function test_earned_reach_cap_cleared_by_mod_check_even_when_flagged(): void
+ {
+ // A moderator look supersedes the crowd signals: checkedat clears the gate outright,
+ // zero views and an outstanding report notwithstanding.
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $this->seedCoveringGroup();
+ $this->reportToMods($msgid);
+ DB::table('messages_groups')->where('msgid', $msgid)->update(['checkedat' => now()]);
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertGreaterThanOrEqual(1, $stats['rippled_in'], 'a mod-checked post ripples');
+ $this->assertSame(0, $stats['reach_capped']);
+ }
+
+ public function test_earned_reach_cap_does_not_apply_to_mod_approved_post(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $this->seedCoveringGroup();
+ $modId = (int) DB::table('messages')->where('id', $msgid)->value('fromuser');
+ DB::table('messages_groups')->where('msgid', $msgid)->update(['approvedby' => $modId]); // mod-approved, zero views
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(0, $stats['reach_capped'], 'mod-approved post is exempt from the cap');
+ $this->assertGreaterThanOrEqual(1, $stats['rippled_in'], 'mod-approved post ripples without any views');
+ }
+
+ public function test_earned_reach_cap_pause_then_resume_accumulates_await_seconds(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $groupB = $this->seedCoveringGroup();
+
+ // Run 1: 1 view < 2, capped.
+ $this->addCleanViews($msgid, 1);
+ $stats1 = $this->service()->process(false, 500);
+ $this->assertSame(1, $stats1['reach_capped']);
+ $this->assertNotNull(DB::table('rippling_reach')->where('msgid', $msgid)->value('awaiting_review_since'));
+
+ // Simulate time passing while paused, and make a LATER tick genuinely due. advanceDue only
+ // re-enters the cap when target > current tick, so age the reach back to the final hazard
+ // step and reset the tick to force a real advance.
+ DB::table('rippling_reach')->where('msgid', $msgid)->update([
+ 'awaiting_review_since' => now()->subSeconds(60),
+ 'arrival' => now()->subHours(7),
+ 'tick' => 0,
+ 'next_expansion_at' => now()->subMinute(),
+ ]);
+
+ // Run 2: a second viewer -> 2 views >= 2, resumes and ripples.
+ $this->addCleanViews($msgid, 1);
+ $stats2 = $this->service()->process(false, 500);
+
+ $this->assertSame(0, $stats2['reach_capped'], 'cap lifted on resume');
+ $this->assertGreaterThanOrEqual(1, $stats2['rippled_in'], 'ripples on resume');
+ $row = DB::table('rippling_reach')->where('msgid', $msgid)->first();
+ $this->assertNull($row->awaiting_review_since, 'await stamp cleared on resume');
+ $this->assertGreaterThanOrEqual(60, (int) $row->awaiting_review_seconds, 'waited time banked');
+ }
+
+ public function test_earned_reach_cap_does_not_reset_await_since_on_repeated_pause(): void
+ {
+ config(['freegle.ripple.earned_reach_enabled' => true, 'freegle.ripple.autoapprove_hold_seconds' => 0,
+ 'freegle.ripple.clean_views_per_group' => 2]);
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(90));
+ $this->seedCoveringGroup();
+
+ $this->addCleanViews($msgid, 1);
+ $this->service()->process(false, 500); // capped, stamp set
+
+ $original = now()->subMinutes(10);
+ DB::table('rippling_reach')->where('msgid', $msgid)
+ ->update(['awaiting_review_since' => $original, 'arrival' => now()->subHours(7), 'tick' => 0, 'next_expansion_at' => now()->subMinute()]);
+
+ $this->service()->process(false, 500); // still 1 view < 2, still capped
+
+ $row = DB::table('rippling_reach')->where('msgid', $msgid)->first();
+ $this->assertEqualsWithDelta(
+ $original->timestamp,
+ \Carbon\Carbon::parse($row->awaiting_review_since)->timestamp,
+ 2,
+ 'awaiting_review_since must not be reset on a second consecutive pause'
+ );
+ }
+
+ public function test_earned_reach_gate_is_dark_by_default(): void
+ {
+ // Flag off (default): a young auto-published post with zero views ripples
+ // exactly as before - no hold, no cap.
+ $this->fakeRouting(3);
+ $msgid = $this->seedSpatialPost(now()->subMinutes(30));
+ $this->seedCoveringGroup();
+
+ $stats = $this->service()->process(false, 500);
+
+ $this->assertSame(1, $stats['initialized'], 'gate off: young post still ripples (no hold)');
+ $this->assertSame(0, $stats['reach_capped'], 'gate off: cap never fires');
+ $this->assertGreaterThanOrEqual(1, $stats['rippled_in'], 'gate off: unreviewed post ripples freely');
+ }
}
diff --git a/iznik-nuxt3/api/MessageAPI.js b/iznik-nuxt3/api/MessageAPI.js
index 5d150533f6..121c229659 100644
--- a/iznik-nuxt3/api/MessageAPI.js
+++ b/iznik-nuxt3/api/MessageAPI.js
@@ -70,6 +70,17 @@ export default class MessageAPI extends BaseAPI {
return this.$getv2('/modtools/messages', params)
}
+ // Mark auto-published posts (Checked/Trusted oversight queues) as reviewed by a
+ // moderator. Pass {groupid, filter} to clear a whole bucket, or {groupid, ids}.
+ markChecked(params) {
+ return this.$postv2('/modtools/messages/markchecked', params)
+ }
+
+ // SysAdmin moderation analytics for a date range ({start, end}).
+ moderationStats(params) {
+ return this.$getv2('/modtools/moderationstats', params)
+ }
+
update(event) {
return this.$postv2('/message', event)
}
diff --git a/iznik-nuxt3/modtools/components/ModHelpApproved.vue b/iznik-nuxt3/modtools/components/ModHelpApproved.vue
new file mode 100644
index 0000000000..b21837ac16
--- /dev/null
+++ b/iznik-nuxt3/modtools/components/ModHelpApproved.vue
@@ -0,0 +1,19 @@
+
+
+ Every live post on your group(s). Checked and Trusted are focused
+ oversight subsets of this.
+
+ Posts that went live automatically from auto-moderated members. Review
+ them here as a safety net; "Mark as checked" clears them. Posts older than
+ a week drop off this queue.
+
+ Posts waiting to go live. Clean posts from auto-moderated members publish
+ automatically after a short delay — the countdown on each post shows how
+ long you have. Opening this page guarantees at least 10 minutes on every
+ post, so nothing disappears while you're looking.
+ Reject or hold anything that shouldn't go live.
+ Posts that went live without moderation from trusted members (set in your
+ group settings). Oversight only — review if you want. Posts older than a
+ week drop off this queue.
+
+ How posts were handled over the period, and whether auto-approving is + working — the rate at which auto-published posts later needed a moderator, + compared with the moderator's verdict on the quality-check sample. +
+ ++ Of the posts a moderator manually checked (the sample), + {{ sampleBadRate }} were bad. Of the posts we + auto-published, {{ autoErrorRate }} later needed a + moderator. +
+{{ verdict }}
++ When the earned-reach gate is on, an auto-published post ripples into + more communities only as review accrues (weight must reach + 2× the number of communities it has reached); below that it + pauses. These show how many posts are gated now and how much total + delay the gate has added over the selected period. +
+No review gate data yet.
No data yet.
@@ -470,6 +505,8 @@ const replySource = ref([]) const hotspots = ref([]) const attributionCaptureFrom = ref('') const heldBySource = ref([]) +// Earned-reach review gate: how many auto-published posts are held now + total delay over the period. +const reviewDelay = ref(null) const stratumLabel = computed(() => stratum.value === 'all' ? 'all-density' : stratum.value @@ -699,6 +736,14 @@ const bullseyeAria = computed(() => { )} in the ${outer.min_lo}-${outer.min_hi} minute ring.` }) +// Format a seconds value as a human-readable duration for the review-gate wait display. +function formatAwaitSeconds(secs) { + if (!secs || secs <= 0) return '—' + if (secs < 60) return '< 1 min' + if (secs < 3600) return `${Math.round(secs / 60)} min` + return `${(secs / 3600).toFixed(1)} h` +} + async function fetchAnalytics() { loading.value = true error.value = null @@ -719,6 +764,7 @@ async function fetchAnalytics() { hotspots.value = metrics?.hotspots || [] attributionCaptureFrom.value = metrics?.attribution_capture_from || '' heldBySource.value = metrics?.held_reply_by_source || [] + reviewDelay.value = metrics?.review_delay || null } catch (e) { error.value = e.message || 'Unknown error' } finally { @@ -796,7 +842,13 @@ function onFilterFetch({ start, end }) { fetchAnalytics() } -defineExpose({ fetchAnalytics, stratum, setStratum, onFilterFetch }) +defineExpose({ + fetchAnalytics, + stratum, + setStratum, + onFilterFetch, + reviewDelay, +})