Skip to content

feat(moderation): agent accounts that vote on and purge spam - #262

Merged
themightychris merged 23 commits into
developfrom
feat/bot-spam-votes
Nov 12, 2026
Merged

themightychris merged 23 commits into
developfrom
feat/bot-spam-votes

Conversation

@codeforphilly-bot

@codeforphilly-bot codeforphilly-bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

Gives the site agent accounts: a bot signs in with a scoped bearer token and can stage spam votes on new members and purge new members who posted spam. Built for GrittyBot, Code for Philly's ops bot, under the policy Chris set on November 12, 2026: purge at once when an account posts spam, and stage a vote when a sign-up only looks like spam.

  • Agent account and grants. An agent is a site record with no GitHub, password or SAML identity. Administrators create it and mint grants with scopes moderation:read, moderation:vote, moderation:purge. The cfpa_… token is shown once and only its hash is stored, privately. Grants last 90 days and revoke immediately.
  • Staged votes. An agent's spam vote hides the member and queues them for the daily sweep three days later, while the grant is active. Any human vote is final. Caps: 20 spam votes a day, on members under 30 days old.
  • Agent purge. POST /api/admin/members/:slug/purge with a reason, flags, 1 to 5 evidence items and dryRun. Refused unless the target is an ordinary member, joined within 7 days, with no human vote, no agent not-spam vote, no Slack sign-in and nothing shared with another member. Cap: 5 a day. One commit per purge, with the evidence paths in trailers.
  • Re-signup block. A purge for spam writes a public spam-purges record. The same GitHub id is refused at sign-up and account linking until staff unblock it. The daily sweep and a staff purge marked "This was spam" write the same record.
  • Bug fix, separable: votes cast on an earlier account with the same slug no longer count for a new account (49bb3cb).
  • Web: staged badges and filter on the roster, an Agents panel with grants and the purge log, Unblock, the "This was spam" checkbox, and the message a blocked sign-in sees.

Verification

  • npm run type-check && npm run lint && npm test passes: 727 API, 188 web, 120 shared tests at 8f4ce58; later commits add two tests and docs. CI is green.
  • An independent review found no blockers and no way for an agent token to exceed its scopes. Its ten findings are fixed with a test each: a renamed member could inherit a purged spammer's votes; concurrent purges could pass the daily cap; an agent account could be claimed by a GitHub user; an agent created without a grant was an ordinary user; plus six smaller ones.
  • Run end to end on the devbox against a throwaway data repo: create an agent, mint, vote, purge, see the block, unblock, withdraw, revoke, in the browser and with curl.
  • The restore runbook was rehearsed. It was wrong in four places and is corrected.

Not verified, and risks

  • published has not adopted the two new sheets. That happens at release time, per docs/operations/releases.md. An API built from this branch will not boot against a branch without them. empty, fixture and legacy-import have them (codeforphilly-data PRs 21, 22, 23, merged).
  • A purge is irreversible for private data. The restore runbook brings back the public record only.
  • The API does not judge spam. It checks that cited evidence exists, belongs to the member and is deleted by the purge. The judgment is the agent's, bounded by the preconditions and caps.
  • Agent vote reasoning and purge reasons may not contain a domain name, so the bot must describe a spam domain without writing it.
  • Nothing here was run against the sandbox or production.

Rollout

  1. Merge, release, adopt the sheets on published, deploy sandbox then production.
  2. An administrator creates the agent (grittybot) with a first grant of read and vote, and puts the token on the bot's host. Runbook section "Agents".
  3. A purge grant later, once the purge log has been watched for a while.
  4. The bot-side command is CodeForPhilly/grittybot issue 22.

Follow-ups

Listed in plans/bot-spam-votes.md. The ones that deserve an issue: an evaluator's vote on an earlier account under the same slug is overwritten by a new vote; scripts/prune-spam.ts lacks the purge cutoff; other routes still apply state after the write lock; purge-orphans skips grant secrets; a restored member keeps the "Slug purged before" badge.

Opened by GrittyBot, Code for Philly's ops bot (AI). 🧡🤖

🤖 Generated with Claude Code

https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs

codeforphilly-bot and others added 3 commits November 11, 2026 19:33
An AI agent can hold a narrow grant to read the moderation roster and
cast spam or not-spam votes. Its votes are labeled agent-<slug>, stage
for three days, and any staff vote overrules them. Proposal for
review; nothing is implemented.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
The operator's policy: purge at once when an account posts spam, vote
when a signup only looks like spam. Adds a separate purge scope with
API-enforced preconditions, a public spam-purges record that blocks the
same GitHub account from signing straight back in, and a fix so a purged
account's votes no longer attach to a new account under the same slug.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
@codeforphilly-bot codeforphilly-bot changed the title docs(specs): agent accounts that stage spam votes (proposal) feat(moderation): agent accounts that vote on and purge spam Nov 12, 2026
codeforphilly-bot and others added 20 commits November 12, 2026 19:18
…scrub rule

Three corrections found while building: the PrivateProfile schema requires an email, the purge log needs a resolved actor beside the stored string, and the scrubbed snapshot must pseudonymize the new slug fields.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
A vote dated before the person's createdAt was cast on an earlier account with the same slug. It no longer counts in the roster, the reversal rule, the sweep or prune-spam.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Schemas, evaluator kinds, sheet configs and contracts for agent grants and the spam purge log, plus the private store for token hashes.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
An agent authenticates with a cfpa_ token checked against a stored hash and is anonymous outside its scopes. Administrators create, mint and revoke grants.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Agent spam votes stage a member for the three-day sweep while the grant is active. Human votes are final. Staff can withdraw all of an agent's votes.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
POST /api/admin/members/:slug/purge with reason, flags, evidence and dryRun. Every precondition is enforced in the API, one commit per purge.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
A purged spammer's GitHub id is refused at sign-up and account linking until staff unblock it. The sweep and staff purges write the same record.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
…gn-in

Roster badges and filter, the Agents panel with grants and the purge log, the week-long banner, and the messages a blocked sign-in sees.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
How an administrator creates an agent and mints a grant, and how to restore a member purged by mistake.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Votes after a purge of the slug, retired slugs, atomic checks, claims, revoke with withdraw, URL and evidence rules. From an independent review of the implementation.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Votes at or before a purge of the slug do not count for a different person. Renames onto a slug with moderation history, and renames of agent accounts, are refused.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
…ore lock

State was applied after the write lock was released, so concurrent purges could pass the daily cap or race a staff vote.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
A GitHub user with a matching login was offered the agent account as a claim candidate.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
An agent created without a grant was an ordinary user. Revoking a compromised token can now withdraw its staged votes in the same commit.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Bare and defanged domains are refused in agent reasons and vote reasoning. A malformed evidence identifier is a 422, not a 500.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Concurrent lastUsedAt writes shared one temp path and could fail or drop a new hash.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Matches the adoption proposed on the data repo's empty branch (codeforphilly-data PR 21). The drift check passes once that merges.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
The revert stops on a conflict after an unblock, the clone must be pulled first, the purge commit is found by its Purge-Id, and Not spam must land before the next sweep.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
…ed purges

Two assertions the plan's validation list named but no test made.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
Sixteen of seventeen validation boxes verified. The contract box closes when the release adopts the two sheets on the published branch.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs
@codeforphilly-bot
codeforphilly-bot marked this pull request as ready for review November 12, 2026 20:21
@themightychris
themightychris merged commit 8a5f24d into develop Nov 12, 2026
1 check passed
@themightychris
themightychris deleted the feat/bot-spam-votes branch November 12, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants