# dormant-sweep A **scheduled** sweep that reversibly limits (silences) long-dormant local yttrx accounts and notifies each one with an appeal/reactivation path. This is the proactive counterpart to the report-driven `abuse-bot` in this repo — roadmap item **A** in secondbrain `yttrx-documentation/anti-abuse.md`. Unlike the rest of the welcomebot (which runs on **admin** over the HTTP API), this sweep runs **on mammut, inside the Mastodon `live-web-1` container**, because the "last login" signal (`users.current_sign_in_at`) only exists in the database — the admin REST API does not expose it. Running inside Rails also means it acts as the `bot` account directly, so **there is no API token or secret to manage on the host.** ## What counts as "dormant" ALL of the following must be older than the cutoff (default **18 months**): | Signal | Source | Why | |---|---|---| | web login | `users.current_sign_in_at` | the headline "hasn't logged in" signal (NULL = never logged in; only counts if the account predates the cutoff) | | app / API token use | `Doorkeeper::AccessToken.last_used_at` | so we don't limit people active via a mobile app (stale web login, live token) | | last post | `account_stats.last_status_at` | so we don't limit lurkers who post without web-logging-in | > **Why all three?** `current_sign_in_at` alone is a trap: it only updates on > *web* login, so at a 6-month cutoff it flagged **1004 / 1548** accounts (~65%) > on the live instance. The three-signal definition flagged the same 1004 (the > instance is just very quiet), but it will not produce false positives as app > usage grows. Starting policy is an **18-month** cutoff → **906** candidates. Always excluded: staff (any assigned role), `DORMANT_ALLOWLIST` handles, and accounts already suspended or silenced. ## Notifications **DM only by default. We do NOT email dormant users.** Blasting a native strike email to ~900 years-stale addresses would bounce heavily, and a bounce spike from `admin@yttrx.com` (which has no Mastodon-side suppression) would damage yttrx's own outbound mail reputation — the same channel signup confirmations use. So: - **Bot DM** (`DORMANT_SEND_DM=true`, default) — the primary, bounce-free notice. A Mastodon DM from the bot; seen if they log back in. (Silence blocks *outgoing* reach, not incoming, so the DM is delivered fine.) - **Strike record** — every action still creates an `AccountWarning` (strike) that is **appealable in the user's account settings**, independent of email. - **Native strike email** (`DORMANT_EMAIL_NOTIFY=false`, default) — OFF. Turning it on emails the registered address AND fires the in-app notification ping (both are gated behind the same `warnable?` check). Only enable with a small `DORMANT_BATCH_CAP` so bounces trickle, and watch the `admin@yttrx.com` inbox (bounces return there; Mastodon won't auto-suppress). - **Mod summary** — a per-run batch summary DM to `DORMANT_MOD_ALERT`. The user-facing reactivation path is **email admin@yttrx.com** (and/or the native in-app appeal on the strike) — not "DM @waffles", because a silenced account's mentions to non-followers are filtered and unreliable until the limit is lifted. The help page (`account-inactive.md`) explains this. ## Files | File | Role | |---|---| | `dormant_sweep.rb` | the sweep itself; run via `rails runner -` inside `live-web-1` | | `dormant-sweep.sh` | host cron wrapper (pipes the rb into the container) | | `dormant-sweep.env.example` | config template → copy to `dormant-sweep.env` on mammut | | `account-inactive.md` | Hugo content for `welcome.yttrx.com/posts/account-inactive/` | ## Config All via env (see `dormant-sweep.env.example`). Key knobs: `DORMANT_MONTHS` (**18**), `DORMANT_DRY_RUN` (**true** by default), `DORMANT_BATCH_CAP` (50/run), `DORMANT_ALLOWLIST`, `DORMANT_EMAIL_NOTIFY` (**false** — DM only), `DORMANT_SEND_DM`, `DORMANT_MOD_ALERT`. ## Deploy (workstation → mammut) ```bash # 1. Copy the runner + wrapper + config to mammut ssh mammut 'mkdir -p /root/dormant-sweep' scp dormant_sweep.rb dormant-sweep.sh dormant-sweep.env.example mammut:/root/dormant-sweep/ ssh mammut 'cd /root/dormant-sweep && cp -n dormant-sweep.env.example dormant-sweep.env && \ chmod +x dormant-sweep.sh' # 2. Edit /root/dormant-sweep/dormant-sweep.env on mammut (keep DORMANT_DRY_RUN=true) # 3. DRY RUN — review what it would do (cap raised for a full preview) ssh mammut 'DORMANT_BATCH_CAP=2000 /root/dormant-sweep/dormant-sweep.sh' | tee /tmp/dormant-dryrun.log # 4. When happy, go live in controlled batches: # set DORMANT_DRY_RUN=false in the env, then either run by hand a few times # or let cron drain it at DORMANT_BATCH_CAP per run. ssh mammut 'crontab -l; echo "30 4 * * 0 /root/dormant-sweep/dormant-sweep.sh >> /var/log/dormant-sweep.log 2>&1" | crontab -' ``` ### Deploy the help page (welcome.yttrx.com, on admin) ```bash scp account-inactive.md admin.yttrx.com:/var/www/html/welcome/content/posts/ ssh admin.yttrx.com 'cd /var/www/html/welcome && ./build.sh && \ git add -A && git commit -m "welcome: add account-inactive (dormant) page"' ``` ## Initial backlog (~906 accounts at the 18-month cutoff) This is a large one-time batch. Recommended rollout: 1. Dry-run (step 3) and **read the list** — confirm no surprises. 2. Set `DORMANT_DRY_RUN=false`, keep `DORMANT_BATCH_CAP` modest (e.g. 50–100). 3. Run a few batches by hand, spot-check the strike emails / appeals queue, then let cron drain the rest. New accounts crossing 6mo afterward are a trickle. ## Rollback / un-limit Every action is logged: `limited @handle ...` lines in `/var/log/dormant-sweep.log`, and the strike note is tagged `[dormant-sweep]`. To lift the whole batch: ```bash # from the log (exact set this script limited) ssh mammut 'grep -oP "(?<=limited @)\S+" /var/log/dormant-sweep.log | sort -u' > /tmp/swept.txt ssh mammut 'docker exec -i $(docker ps -f name=live-web-1 -q) bin/rails runner " STDIN.read.split.each { |u| a = Account.find_local(u); a.unsilence! if a&.silenced? } "' < /tmp/swept.txt # or by the strike tag, regardless of the log: ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner " AccountWarning.where(\"text LIKE ?\", \"%[dormant-sweep]%\").find_each do |w| w.target_account.unsilence! if w.target_account&.silenced? end "' ``` A single account: in the admin UI (Moderation → the account → Undo limit), or `Account.find_local(\"handle\").unsilence!`. ## Notes / gotchas - Runs as `bot` (Admin). `Admin::AccountAction(type: 'silence')` is the exact call the abuse-bot makes via the API — same reversible, no-`report_id` behaviour, just invoked in-process. - mammut uses the **`docker compose` plugin**; `tootctl`/`rails` run inside `live-web-1` (`docker exec $(docker ps -f name=live-web-1 -q) ...`). - Verified against Mastodon **4.6.0** (2026-06-18): `Admin::AccountAction`, `PostStatusService`, `User.confirmed`, and the three signal columns all exist.