feat(moderation): post-moderation for member posts (auto-publish + oversight + analytics)#639
Open
edwh wants to merge 26 commits into
Open
feat(moderation): post-moderation for member posts (auto-publish + oversight + analytics)#639edwh wants to merge 26 commits into
edwh wants to merge 26 commits into
Conversation
✅ Deploy Preview for golden-caramel-d2c3a7 canceled.
|
4845c67 to
d580d39
Compare
0b884e0 to
50d0372
Compare
50d0372 to
4e2a433
Compare
30b648f to
78b51e0
Compare
78b51e0 to
c9b5fd4
Compare
b66c7bb to
80a7dc4
Compare
edwh
added a commit
that referenced
this pull request
Jul 12, 2026
iznik-batch is bind-mounted into the batch container (./iznik-batch -> /var/www/html), so docker cp'ing onto it unlinks+recreates the host file and races the delete-propagation path, deleting newly-created host files (the PR #639 vanishing-file bug). Remove iznik-batch from the sync + watch list; the bind mount already exposes host edits live.
Members whose group posting status is NULL ('auto-moderated' — never explicitly
set) previously had their posts stuck in Pending until a mod acted or the 48-hour
fallback fired. NULL is now reinterpreted as a fast, safe path: their
content-check-clean posts auto-approve after a configurable delay (default 20 min),
giving mods and microvolunteers a window to intervene, unless a danger signal is
present, with a configurable quality-check sample held back for manual review.
Backend (iznik-batch):
- AutoApproveCleanService + messages:auto-approve-clean command (scheduled every
minute). Eligible = NULL posting status, content-check clean (checked_at set,
reasons NULL), past the per-group/site delay, group not moderated/closed/Big-Switch,
not held/deleted/spam-flagged.
- Danger-signal vetoes: microvolunteering rejects, mod notes, recent negative mod
logs (rejections/deletions/modmails/spam classifications), known-spammer records,
pending membership reviews.
- Quality-check sample held deterministically per message.
- Spam unaffected: only posts that already passed messages:contentcheck are eligible;
suspect posts keep their reasons and stay Pending (or move to Spam) as before.
- config/freegle.php autoapprove block (delay_minutes=20, quality_check_percent=0,
danger_log_days=90); per-group override via settings.autoapprove.* (0/absent = site
default). No schema migration and no routing change.
Go API:
- /modtools/messages gains a filter param: autoapproved (approvedby IS NULL),
recentjoin (joined <7d), outsidecga (location outside group polygon).
Frontend (modtools):
- Approved page filter dropdown (defaults to All; switches to summary view for
Auto-approved).
- Group settings controls for the delay and quality-check percentage.
Tests: 25 service + 4 command unit tests (Laravel); 3 Go endpoint tests.
…sted/Approved
Replace the Approved-page filter dropdown with four nav views:
- Pending — unchanged: full pending queue (incl. posts still in their auto-approve
hold window, where a mod can stop them before they go live).
- Checked — live posts auto-approved via the automated checks, from auto-moderated
(NULL posting status) members. Oversight only.
- Trusted — live posts that went live without moderation from trusted (group-settings)
members. Oversight only.
- Approved — unchanged: all live messages (Checked/Trusted are focused subsets).
- Go /modtools/messages: replace the autoapproved/recentjoin/outsidecga filters with
view filters checked (approvedby NULL + poster status NULL) and trusted
(approvedby NULL + poster status DEFAULT/UNMODERATED).
- Session work counts: add checked + trusted counts (last 24h, informational/blue,
not added to the actionable total). Nav shows Pending red, Checked/Trusted blue,
Approved none.
- New Checked/Trusted pages default to summary view; revert the Approved-page dropdown.
- Tests: replace the Go filter tests with checked/trusted; add Vitest specs for both pages.
…edby/checkedat) Auto-published posts (auto-moderated Checked + trusted Trusted members) have no field recording that a mod has reviewed them — approvedby is NULL, contentcheck_* is only the automated pass. Add checkedat/checkedby to messages_groups so the Checked/Trusted oversight queues become real, clearable 'unchecked' queues. - Migration: messages_groups.checkedat + checkedby (FK users), composite index. - session.go: Checked/Trusted counts now = UNCHECKED within the auto-check window (was 24h volume), so the badge is outstanding work that drops as mods check. - message_list.go: checked/trusted views show only unchecked-within-window posts. - POST /modtools/messages/markchecked: mark posts (or a whole bucket) checked. - Window is a named constant (utils.MESSAGE_CHECK_WINDOW_DAYS=7), not hardcoded in SQL strings; posts auto-check after the window so the queue can't pile up.
…queues - Checked/Trusted pages get a 'Mark all as checked' button that clears the bucket (POST /modtools/messages/markchecked) and refreshes the nav badge. - MessageAPI.markChecked + message store action. - Go tests: checked filter excludes already-checked posts; markchecked endpoint sets checkedat/checkedby and removes posts from the queue; non-mod gets 403. - Vitest: markAllChecked clears the bucket and refreshes work counts.
Adds a 'Moderation' tab to the SysAdmin section (Admin/Support) to judge whether auto-approving is working over a selectable period: - Where posts went: arrived, manually approved/rejected, auto-approved (Checked delay path vs 48h fallback), trusted (immediate). - Did auto-approving go wrong: of auto-approved posts, how many a mod later marked checked, and how many later needed intervention (rejected/deleted/edited/held) — the auto-publish error rate. - Quality-check sample: posts held back for manual review, and how many a mod then rejected — cross-checked against the auto-publish error rate to give a verdict. - Migration: messages_groups.quality_sample marks posts the auto-approve service held back (AutoApproveCleanService sets it), so the sample is measurable. - Go GET /modtools/moderationstats (Admin/Support) computes the metrics from the logs table + messages_groups, parallel per-metric queries. - MessageAPI.moderationStats + message store action; ModSysAdminModerationStats.vue. - Tests: Go endpoint (admin/forbidden/dates/counts), Laravel quality_sample marker, Vitest component (fetch on mount, percentages, re-fetch).
…x (A1) Nullable timestamp with a groupid composite index. Both auto-approve services respect a hold predicate (next commit); NULL = no hold (behaviour unchanged for existing rows). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lter (A2,D1,D3,D6) - A2: both AutoApproveCleanService and AutoApproveService skip rows whose autoapprove_hold_until is set and in the future. - D1: add the cross-group Spam-collection whereNotExists guard to the clean service (Discourse #9654 parity) so a message in Spam on another group is never auto-approved. Test included. - D3: exclude already-sampled rows from the candidate query and only count held_quality outside dry-run, fixing per-minute rescans and stat inflation. - D6: assert an approved OFFER queues TASK_FREEBIE_ALERTS_ADD (WANTED does not). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A3,A4,D2,D4,D9,D10,D11) - A3: ListMessagesMT bumps autoapprove_hold_until >=NOW()+10m (extend-only) for fetched Pending rows, so loading the page guarantees a review window. - A4: GET /message/:id exposes per-group autoapproveat for Pending posts viewed by a mod (new message/autoapproveat.go). Accurate: a concurrent danger-signal query (mirrors AutoApproveCleanService::hasDangerSignals) + group-allows + quality-sample => clean-path delay, 48h fallback, or nil (danger/spam/held). - D2: reword session.go/utils.go comments — older posts drop off the queue (no checkedat write), so AutoModChecked keeps measuring real mod checks. - D4: scope moderationstats Trusted NOT EXISTS by groupid (cross-post miscount). - D9/D10/D11: markchecked guards collection=Approved on the ids path, 400 on an unknown filter, 403 for a non-mod with groupid=0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ds (A3,A4,D9,D11) TestListMessagesMTPendingBumpsHold (extend-only), TestAutoapproveatPendingModGating (clean=>time, danger=>nil, non-mod=>nil), TestMarkCheckedSpecificIDs (collection guard + group scoping), TestMarkCheckedCrossGroupAndNonMod (403 paths). All green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (A5,C,D2,D5,D8,D12) - A5: live countdown badge on Pending messages (ModMessage.vue) from per-group autoapproveat: >1h muted, <=30m prominent, <=0 Auto-approving..., null=hidden. - C: ModHelpPending/Checked/Trusted/Approved help boxes (useHelpBox), wired into the four queue pages, explaining what goes where. - D2: oversight empty-state copy now says posts "drop off this queue" (not the inaccurate "treated as checked"). - D5: drop the unused [[term]] route segment from the checked/trusted pages. - D8: align the moderation-stats auto-approved row label (Checked delay + 48h fallback). - D12: remove orphaned autoChecked/autoFallback from the stats spec fixture. Vitest: countdown (5 cases), four ModHelp specs, and the stats spec all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the reverted autoapproved/recentjoin/outsidecga Approved-page dropdown description with the shipped checked/trusted pages, countdown + hold, and help boxes; fix status rows 6-7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… counts (D4,D7) TestModerationStats_TrustedScopedByGroup proves a cross-posted trusted row still counts despite an Autoapproved log on another group. TestModerationStats_AllFieldsExactCounts asserts exact counts for all nine fields in an isolated historical window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unmasked by srvx) The srvx lock failure had stopped vitest from ever running on this branch, so several of its own specs were broken-but-unrun. With the lock fixed they now run: - pending/approved spec: page renders <ModHelpPending>/<ModHelpApproved> (this branch's help panels) but the spec didn't stub them -> 'Failed to resolve component' Vue warning -> strict no-warning check fails. Add the stubs. - checked/trusted spec: imported the wrong page path (.../[[id]]/[[term]].vue, which doesn't exist) instead of the actual .../[[id]].vue, so the files failed to load. Fix the import + add the ModHelpChecked/ModHelpTrusted stub. - ModMessage auto-approve countdown badge: '~Nh' used Math.floor, so now+3h (minus the few ms elapsed = 2h59m) showed '~2h'. Round instead, since the label is explicitly approximate. Verified locally: 66 message-page specs + 468 ModMessage specs all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgWPgdxq4drrQ1jjrynVVZ
…ath + oversight queues
Now that rippling-out is live, a rippled-in messages_groups row (rippled_in=1) is
Approved-by-default with approvedby/checkedat NULL and an auto-joined NULL
ourPostingStatus membership, which collides with this branch's new post-moderation
surfaces:
- AutoApproveCleanService candidate query had no rippled_in filter. Once
ContentCheckService stamps a rippled-in Pending row (non-default
rippled_in_pending_hours > 0), the clean path would approve it at +20min,
bypassing the configured rippled-in mod-veto window. Rippled-in rows belong
solely to AutoApproveService's rippled_in branch (which carries the
Taken/Received guard, config-driven rippled_in_pending_hours and the recentLogs
bypass). Guard the clean candidate query with ->where('mg.rippled_in', 0).
- The Checked oversight queue, the session work-count badge and mark-all-checked
(message_list.go, session.go, markchecked.go) matched rippled-in Approved rows
on the receiving group, polluting the queue and inflating the AutoModChecked
analytic. Add AND mg.rippled_in = 0.
- The autoapprove_hold_until extend-only UPDATE could keep deferring a rippled-in
Pending row's approval past its window; exclude rippled_in rows (defensive).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwC1Gw6UczMnthan5gPJD4
…o-approve x rippling - AUTO-APPROVE-FOR-MODERATORS.md: mod-facing guide for post-moderation, modelled on RIPPLING-OUT-FOR-MODERATORS.md (queues, countdown, reject, and how reach is earned by review under rippling). - docs/superpowers/specs/2026-06-25-autoapprove-earned-reach-review-gate-design.md: design for the earned-reach review gate - 1h hold, per-N quorum (weight 2N; microvol 1, mod 2), capped reach (B1), reviewer pools (both in max ripple area, closeness-ordered), reject action, and await-review delay tracking on the SysAdmin rippling page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwC1Gw6UczMnthan5gPJD4
markchecked gains a `reject` mode: instead of marking auto-published posts checked, it pulls the specified posts back to Pending and holds them by the acting mod (collection=Pending, heldby=mod, checkedat cleared). This makes the oversight queue actionable - a mod who finds a bad auto-published post can take it straight back out of the live feed for a proper decision. Setting Pending removes it from messages_spatial so rippling stops drawing it, and heldby blocks the auto-approve cron from re-publishing it. Targeted (explicit ids) only - no bulk reject. Scoped to the mod's groups, Approved rows. Test: TestMarkCheckedReject (pulls to Pending+held, clears checkedat, requires ids => 400). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwC1Gw6UczMnthan5gPJD4
…review instrumentation Dark behind RIPPLE_EARNED_REACH_ENABLED (default off): when off ExpandService behaves exactly as before. When on, an auto-published post (approvedby NULL) must be 1h old before it starts rippling (RIPPLE_AUTOAPPROVE_HOLD_SECONDS; a mod approve/check skips it), and may ripple into N communities only while its review weight >= 2*N (weight = 1 per microvol Approve + 2 per mod check). Below that it pauses (no new rippled-in rows, reach_capped++, awaiting_review_since stamped); when weight catches up it resumes and banks the waited time into awaiting_review_seconds. Mod-approved posts are exempt. - config: earned_reach_enabled + autoapprove_hold_seconds. - ExpandService: 1h hold in initialiseNew; reviewWeight(); earned-reach cap + await-review instrumentation in rippleIntoNewGroups. - migration: rippling_reach.awaiting_review_since + awaiting_review_seconds (additive, idempotent SQL). - 13 ExpandService tests (hold on/off/skip/bypass; cap N=1/N=2 by microvol vs mod-check weight; mod-approved exempt; pause->resume banks await seconds; await stamp not reset on re-pause; gate dark by default). Full ExpandService suite green (60/60). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwC1Gw6UczMnthan5gPJD4
…reject
Build out the remaining earned-reach review-gate slices (all dark behind
RIPPLE_EARNED_REACH_ENABLED, default off):
- Slice B (microvolunteering.go): bound the CheckMessage approved-message
reviewer pool to each post's rippled-out reach. When the gate is on and the
reviewer has a location (settings.mylocation, falling back to lastlocation),
candidates are restricted to posts whose rippling_reach max_drive_min covers
the reviewer (ST_Distance_Sphere <= max_drive_min * 1400 m) and ordered
nearest-first. Posts with no reach row or no computed max_drive_min are
included regardless (fail-open). Gate off => byte-identical to the prior query.
- Slice C (rippling/metrics.go + ModSysAdminRippling.vue): surface review-gate
await metrics on the sysadmin rippling page - posts awaiting review now,
average current wait, previously-held-now-resumed, and post-hours delayed over
the selected period. Two fail-soft passes over rippling_reach
(awaiting_review_since / awaiting_review_seconds); all zero if unmigrated.
- Slice D (ModMessageButtons.vue + stores/message.js + checked/trusted pages):
per-message "Reject (back to Pending)" button on the checked/trusted oversight
queues, shown only for Approved posts (oversight && approved). Pulls an
auto-published post back to Pending via markChecked({reject:true}).
Tests: 2 Go reviewer-pool tests (reach-bounded, nearest-first), 3 Go metrics
tests (fields present, awaiting, post-hours), 4 Vue oversight-button tests,
4 Vue review-gate-tile tests. Full Go suite green (3293); both specs green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ount The batch container bind-mounts ./iznik-batch at /var/www/html, so 'syncing' iznik-batch files into it was a self-copy: docker cp's unlink+recreate re-fired inotify (an endless sync loop, visible in host-scripts logs), and the delete propagation racing that unlink window ran docker exec rm THROUGH the mount, deleting the just-edited file on the host. Remove the iznik-batch mapping entirely - the bind mount already delivers every edit instantly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
Rollout gate: the clean-path auto-approve now ships OFF. FREEGLE_AUTOAPPROVE_ENABLED is the master switch; FREEGLE_AUTOAPPROVE_TRIAL_GROUPS phases a per-group trial while it stays off (mirroring the rippling group experiment). The Go Pending countdown mirrors the gate (a countdown that will never fire must not be shown), and compose passes the flags to both apiv2 services so the cron and the countdown cannot disagree. Fixes from the adversarial re-review of the branch merged onto current master: - Trusted oversight queue, its badge count and the moderation-stats Trusted analytic gain the rippled_in=0 guard Checked already had - rippled-in copies polluted all three. - markchecked targeted check/reject gain the same guard: oversight actions only ever touch a post's origin row. - Oversight reject now writes the Message/Rejected log (feeds the error-rate KPI and the danger-signal veto on the member's next post) and synchronously stops the post's rippling_reach row instead of leaving the reach engine to notice for up to ~6 minutes. - AutoApproveService's 48h fallback no longer sweeps up quality-sample posts (they wait for a human, keeping the sample-vs-population comparison honest), re-checks the mod hold at write time so a page-view hold bumped mid-run wins, and only logs Autoapproved when the approval actually landed. - AutoApproveCleanService approval seeds messages_spatial immediately (parity with the Go manual-approve path) so the post is browsable/reach-eligible at once. - messages:auto-approve-clean gets master's cron conventions: withoutOverlapping(15) instead of an unbounded 24h mutex, and a 500-per-tick candidate bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
…tions table Rewritten around the moderator discussion in Discourse topic 9620 (Evolution of our approach): every substantial objection raised there - 16 rows, with who raised it and post references - now opens the document, each answered honestly, including the ones the design does not fully mitigate (new-member post quality, the volunteer-morale cost, the trust-differentiator judgment call). Documents the switched-off-by-default rollout, the trial phasing, and flags plainly that the analytics live on a page ordinary moderators cannot currently see, so trial numbers must be published to them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
…onsultation and autonomy rows Per review: personal names removed from the objection attributions (post numbers kept - the thread carries the names for anyone who wants them); the consultation row answers the process objection on its own terms (input visibly changed the design; 'no' remains an available outcome; success criteria to be stated before the trial) instead of pointing at rollout switches; the autonomy row states plainly that groups will not be able to opt out of post-moderation itself, and lists what does stay under local control; the Fair Chance row notes the model is old and a better allocation design deserves its own proposal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
Requiring microvolunteer/moderator approval per community before a post may ripple could never keep pace with post volume - it would quietly stop rippling for most posts. The earned-reach cap now keys on views-without-complaint: a post may ripple into N communities while RIPPLE_CLEAN_VIEWS_PER_GROUP (default 5) distinct members per community have seen it (messages_likes View, poster excluded) and nobody has flagged it. A flag - a member report to the mods (User2Mod chat message referencing the post) or a microvolunteer Reject - pauses all further spread; a moderator look (approvedby/checkedat) clears the gate outright. The 1h hold, pause instrumentation and dark-by-default flag are unchanged. Mod guide updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
By the thread's own figures (post 1: 46% of posts held for moderation), roughly half of what ripples today was never human-approved - it passed the automated checks and went. Framed accurately, the earned-reach gate ADDS a brake that today's rippling lacks, which is a stronger and truer argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHiCAjJKX7CemYZEJUntt9
2acc6fc to
e4a9ba6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Post-moderation for member posts, dark by default: content-check-clean posts from auto-moderated (NULL posting status) members publish automatically after a short delay, moderators oversee them after they are live from dedicated queues, a review gate ties how far an unreviewed post can ripple to how much human review it has had, and analytics measure the error rate.
Deploying changes nothing: the clean-path auto-approve is gated behind
FREEGLE_AUTOAPPROVE_ENABLED(default off), withFREEGLE_AUTOAPPROVE_TRIAL_GROUPSfor a phased per-group trial while the master switch stays off. The earned-reach gate is separately dark behindRIPPLE_EARNED_REACH_ENABLED(default off). The decision to switch on rests with the Board, per the moderator discussion in Evolution of our approach (post 301).AUTO-APPROVE-FOR-MODERATORS.mdis the moderator-facing guide. It opens with an objection/mitigation table covering every substantial objection from that thread (with post references), stated honestly - including the ones the design does not fully answer.Publishing path (Laravel)
messages:auto-approve-cleanruns every minute (500-candidate batch, 15-minute overlap mutex):messages:contentcheckfound clean waits a configurable delay (default 20 min, per-group overridesettings.autoapprove.delay_minutes), then auto-approves (approvedbyNULL) - unless a danger signal vetoes it: mod note on the member, microvolunteer rejection, negative moderation log in the last 90 days, known/suspected spammer record, or an outstanding membership review. Spam on any group blocks approval on all groups.settings.autoapprove.quality_check_percent) stays in Pending for a human verdict. Sampled posts are excluded from every auto-approver, including the 48h fallback, so the sample-vs-population error comparison stays meaningful.messages_spatialin the same transaction (identical to the Go manual-approve path), so the post is browsable and reach-eligible immediately.AutoApproveService, which carries the rippling guards (Taken/Received,rippled_in_pending_hours, per-candidate bypass). Both approvers honour the mod hold (autoapprove_hold_until) in the candidate query and at write time, so a hold bumped mid-run wins; theAutoapprovedlog is written only when the approval lands.The service does not act on closed or unpublished groups, groups under
overridemoderation=ModerateAll, themoderatedsetting orfullymoderatedrules (operational code-level guards - policy offers no per-group opt-out from post-moderation itself); explicit member posting statuses always win.Moderator experience (ModTools + Go)
autoapproveat), computed with the same eligibility rules as the cron, including the rollout gate - no countdown is shown for a path that will not run. Opening the Pending queue bumps an extend-only 10-minute hold on every fetched post: nothing auto-approves while a mod is looking at it.Message/Rejectedlog (which feeds the moderation stats and the danger-signal veto on that member's next post), and stops the post'srippling_reachrow synchronously, so the reach engine cannot ripple it anywhere new while the spatial prune catches up. Reject only ever touches a post's origin row; rippled-in copies are retracted by the engine's stale-post retraction.Earned-reach review gate (
RIPPLE_EARNED_REACH_ENABLED)approvedbyNULL and unchecked) waits ~1h (RIPPLE_AUTOAPPROVE_HOLD_SECONDS) before it starts rippling.RIPPLE_CLEAN_VIEWS_PER_GROUP(default 5) distinct viewers per community (messages_likestype View, poster excluded) and nobody has flagged it. A flag - a member report to the mods (aUser2Modchat message referencing the post, which is what the report flow sends) or a microvolunteer Reject - pauses all further spread. A moderator look (approvedby/checkedatanywhere) clears the gate outright; mod-approved posts are exempt throughout.rippling_reach.awaiting_review_since/secondsinstrument paused time and feed the "Review gate" panel on the SysAdmin rippling page.Known limitations while the gate is on: a gate-held post has no reach row yet, so it is absent from the reach-based nearby browse feed (email/digest/group pages unaffected) until it starts rippling; and the SysAdmin review-delay panel measures cap-pauses, not the pre-initialisation 1h hold.
Rollout
FREEGLE_AUTOAPPROVE_TRIAL_GROUPS=<ids>(+ optionally the earned-reach gate) on volunteer groups; non-zero quality sample; watch the error rate.FREEGLE_AUTOAPPROVE_ENABLED=trueis the eventual master switch.Dev environment
file-sync.shdoes not mapiznik-batchto any container: the batch container bind-mounts./iznik-batch, so a sync there would be a self-copy whose unlink window can race the delete-propagation path intodocker exec rm-ing the file on the host, silently destroying working-tree edits.Schema (additive, no backfill)
messages_groups.checkedat/checkedby(oversight tracking),quality_sample,autoapprove_hold_until(+ index).rippling_reach.awaiting_review_since/awaiting_review_seconds(+_migration.sqlfor prod).Test plan
AutoApproveCleanServiceTest(danger signals, delays/overrides, quality sample, cross-group spam guard, hold predicate, immediate spatial seeding, rollout gate off/trial/on),AutoApproveServiceHoldTest(48h hold, write-time hold re-check, quality-sample exclusion),ExpandServiceTestearned-reach suite (hold, per-N cap, weights, pause/resume, dark-by-default), migration test.autoapproveatgating incl. the rollout gate (48h estimate when off, 20-min for trial groups),markchecked(guards, cross-group, reject incl. log row + reach stop + rippled-in protection), oversight queue filters incl. rippled-in exclusion,moderationstats, rippling review-delay metrics.