Staff-facing endpoints behind the admin members screen:
a newest-first roster of members with their footprint on the site, and a way to
record a human spam verdict that the offline spam pipeline treats as final
(see behaviors/spam-exclusion.md). An
agent holding a grant may read the roster, cast staged votes
that any human vote overrules (Agent votes) on members who only
look like spam, and purge a new member who has posted spam
(Agent purges). Every spam purge, by anyone, leaves a public
spam-purges record that stops the same GitHub
account signing up again until staff unblock it.
All endpoints require accountLevel ∈ {staff, administrator}, or an agent
grant with the scope named below (agent:<scope> in
authorization.md). Anyone else
gets 404 not_found — like the other staff surfaces, the endpoints' existence
is not a signal.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/admin/members |
staff | agent:moderation:read |
Paginated roster, newest signup first, with vote state and footprint counts. |
GET |
/api/admin/members/:slug |
staff | agent:moderation:read |
One member's full footprint plus every vote on record. |
POST |
/api/admin/members/:slug/vote |
staff | agent:moderation:vote |
Record the caller's verdict; applies the side effect described below. |
DELETE |
/api/admin/members/:slug/vote |
agent:moderation:vote |
Withdraw the calling agent's own vote. |
POST |
/api/admin/members/:slug/purge |
agent:moderation:purge |
Purge a new member who posted spam, citing the evidence. |
GET |
/api/admin/spam-purges |
staff | agent:moderation:read |
The spam-purge log, newest first. |
POST |
/api/admin/spam-purges/:id/unblock |
staff |
Let the purged GitHub account sign up again. |
Staff purge a member from the person's Danger Zone (api/people.md → purge, administrators only); this file holds only the agent's purge.
person-evaluations records are keyed by slug, and a purge leaves them in
place as the record of why. A later sign-up can get the same slug (a GitHub
sign-up takes its slug from the GitHub login, and the purge freed it). A vote
whose evaluatedAt is earlier than the person's createdAt was cast on
that earlier account, and so was a vote dated at or before the newest
spam-purges record for this slug whose
personId is another person's (the purge ended that account; anything
dated up to it was about the account it ended). Every rule in this file and in
person-lifecycle.md ignores such a vote: it
is not the latestVote or stagedVote, it does not decide, it does not set
purgeAfter, it does not trip human_verdict_exists, and the reversal rule
does not see it. The detail response lists such votes apart, as
earlierVotes, and the row carries the slug-purged-before signal.
The purge rule covers what createdAt cannot: a person who existed before the
purge and later took the slug. A rename can no longer do that (a person is
never renamed onto a slug with a spam-purges record or any
person-evaluations under it:
slug-handles.md),
but the rule holds for any way a slug changes hands.
(Before this rule the earlier account's votes attached to the new one: a re-registration showed the old staff vote as its own, an agent could not act on it, and a self-deactivation would have put it straight into the sweep on a vote nobody had cast on it.)
| Param | Type | Notes |
|---|---|---|
q |
string | Substring match on fullName, slug, bio, and (staff-visible) email — searched for every query, so .com finds every member on that domain. For an agent caller, only the email's domain is searched. Applies to the whole roster, then paginates. |
vote |
enum | none (no human vote yet — includes agent-staged rows) | staged (no human vote, and an agent vote) | spam | legit. spam and legit filter on the latest human vote. |
origin |
enum | imported (has a laddr legacyId) | signed-up (created on this site through GitHub). |
joinedAfter, joinedBefore |
ISO date | Inclusive bounds on createdAt. |
includeDeactivated |
bool | Default true — moderation needs to see what it hid. |
sort |
sort | Default -createdAt. Allowed: createdAt, fullName, lastLoginAt. |
page, perPage |
int | Default perPage = 50, max 200. |
{
"success": true,
"data": [
{
"id": "…", "slug": "jane", "fullName": "Jane Doe", "avatarUrl": "…",
"createdAt": "2026-09-17T22:41:00Z",
"deletedAt": null,
"origin": "signed-up",
"email": "jane@example.org",
"emailDomain": "example.org",
"hasGitHubLink": true,
"github": { "login": "janedoe", "accountCreatedAt": "2019-03-02T…", "publicRepos": 12, "followers": 40, "status": "ok", "checkedAt": "…" },
"lastLoginAt": "2026-09-18T01:12:00Z",
"signInCount": 4,
"lastSlackSsoAt": "2026-09-18T01:15:00Z",
"emailBounce": null,
"bioExcerpt": "first ~160 chars of bio, markdown stripped",
"footprint": { "memberships": 2, "updates": 1, "buzz": 0, "blogPosts": 0, "helpWantedInterest": 1, "tags": 3 },
"latestVote": { "verdict": "legit", "voter": { "slug": "chris", "fullName": "Chris Alfano" }, "evaluatedAt": "…" },
"stagedVote": null,
"purgeAfter": null,
"signals": ["slack-sso", "has-footprint"],
"attention": 0
}
],
"metadata": { "timestamp": "…", "page": 1, "perPage": 50, "totalItems": 12661, "totalPages": 254 }
}Field notes:
origin—importedwhen the record carries a laddrlegacyId, elsesigned-up(every new-site account arrives through GitHub OAuth).email,github,lastSlackSsoAt,emailBouncecome from the private profile (behaviors/private-storage.md).githubis present whenever the person is GitHub-linked;statusisok,gone(GitHub 404s the account — deleted or suspended), orunknown(never probed). Rows on the requested page are re-probed when their record is older than a day, with bounded concurrency; a failed probe keeps the old record.lastLoginAt/signInCountcome from session metadata (sessions issued to the person).signalsare row-local facts, never machine verdicts. Negative ones count towardattention:never-signed-in,no-avatar,no-bio,bio-links:<n>,email-name-mismatch(email local-part shares no ≥3-letter token with the display name),email-bounced:<type>,github-gone,github-new-account(< 30 days),github-no-activity(0 repos and 0 followers),slug-purged-before(aspam-purgesrecord names this slug: an earlier account under it was purged as spam). Informational:slack-sso,has-footprint.
purgeAfter is when the scheduled spam purge will take the member — three days after the newest deciding vote, when every deciding vote is spam and the member is still deactivated and not staff — or null. The deciding votes are the human votes; when there are none, the votes of agents that still hold an active grant.
None of it is reachable by a non-staff caller (who gets 404 anyway). latestVote is null
when no human has voted; it never holds an agent's vote. Every vote field here
counts only votes on this account (Votes belong to one account).
stagedVote is the newest agent vote when no human has voted on the
member, else null:
"stagedVote": {
"verdict": "spam",
"agent": { "slug": "example-bot", "fullName": "Example Bot" },
"reasoning": "Business name; GitHub account 3h old; promotional link added to bio 6 min after sign-up.",
"flags": ["business-name", "github-new-account", "bio-added-link"],
"evaluatedAt": "…",
"counts": true
}counts is false when the agent no longer holds an active grant: the vote
stays on the row as a record but no longer queues a purge.
emailDomain is the part of email after the @, or null.
The row above, plus the full footprint and vote history:
{
"success": true,
"data": {
"person": { …Person as GET /api/people/:slug for staff, including email and deletedAt… },
"footprint": {
"memberships": [ { "project": { "slug", "title" }, "role", "joinedAt" } ],
"updates": [ { "project": { "slug", "title" }, "number", "title", "postedAt" } ],
"buzz": [ { "project": { "slug", "title" }, "slug", "title", "postedAt" } ],
"blogPosts": [ { "slug", "title", "postedAt" } ],
"helpWantedInterest": [ { "project": { "slug", "title" }, "role": { "title" }, "createdAt" } ],
"tags": [ { "handle", "type" } ]
},
"votes": [
{ "kind": "human", "verdict": "spam", "reasoning": "…", "flags": ["manual-override"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" },
{ "kind": "agent", "verdict": "spam", "reasoning": "…", "flags": ["github-new-account"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" }
],
"earlierVotes": [],
"earlierPurges": [ { "id": "…", "purgedAt": "…", "by": "human-jane", "reason": "…" } ]
}
}votes is newest first, human and agent votes together; kind tells them
apart (from the evaluator prefix — spam-exclusion.md).
earlierVotes (same shape) are votes on an earlier account under this slug,
and earlierPurges the spam-purges records for
this slug (Votes belong to one account).
Everything here is drawn from the in-memory public state — nothing is fetched
from elsewhere.
404 not_found— slug doesn't exist, or the caller is not staff
Record the caller's verdict on this person as a person-evaluations record with
evaluator = "human-<callerSlug>" — or "agent-<agentSlug>" when the caller is
an agent (one record per voter per person — voting again replaces the caller's
previous record). Committed through the write mutex as the caller, so the vote
carries the voter's pseudonymous git identity.
{ "verdict": "spam" | "legit", "reasoning": "optional, ≤ 1000 chars" }An agent also sends flags (0–10 short reason tags, each
^[a-z0-9][a-z0-9:-]{0,39}$), and reasoning is required for it (1–1000
chars): a person reviewing the staged vote reads it. An agent's reasoning
goes into a public commit, so it is held to the purge reason's rules: no
URL and no control characters (the request); 422,
reasoning. Describe a link or a domain (a disposable email domain, a link to a pharmacy shop) instead of writing it. Human votes carry
flags = ["manual-override"] and confidence = 1; agent votes carry
["agent", …flags] and no confidence.
Side effects (per behaviors/person-lifecycle.md)
verdict = "spam"→ the person is deactivated in the same transaction (deletedAt = now()if not already set).verdict = "legit"→ if the person's previous latest vote, human or agent, wasspam(i.e. this vote reverses a moderation hide — a staff member's or an agent's),deletedAtis cleared. A self-deactivation is left alone.- A human vote on a member an agent had staged is an ordinary human vote: from
then on the human votes decide and the agent's record is only history. That
is how staff overrule (
legit) or confirm (spam) a staged vote. - Voting on yourself, or on an agent account, is rejected.
{ "success": true, "data": { "person": Person, "vote": { …the recorded vote… }, "latestVote": { … } } }422 validation_failed— bad verdict, reasoning too long or (agent) missing or carrying a URL or a control character, bad flags,slugis the caller, orslugis an agent account409 conflict— (agent)error.code = "human_verdict_exists","member_too_old"or"protected_account"; see Agent votes429 rate_limited— (agent) over its daily spam-vote allowance404 not_found— slug doesn't exist, or the caller is not staff and holds no grant with the scope
Agent only. Deletes the calling agent's own agent-<agentSlug> record on this
person, so an agent can take back a ruling as freely as it made one. If that
was the person's latest vote and it was spam, and no other agent's spam
vote remains, the person is reactivated (the reversal rule). Response 200 { person, stagedVote };
404 when the agent has no vote on the person. Staff change a vote by voting
again; clearing a human vote is not offered.
This is how the "pending doing anything that makes them not look like spam"
half of the policy works: when a member the agent staged does something that
makes them look legitimate, the agent clears its vote (back to unvoted, in the
default review view) or votes legit (when it has positive evidence). What
counts is the agent's judgment, listed in the bot's own policy; the site only
records it. Once a human has voted on the member the agent's vote is history
and stays: DELETE answers 409 human_verdict_exists, as withdraw-votes
leaves it (agents.md).
A request carrying an agent grant (Authorization: Bearer cfpa_…) sees the
roster as staff do, less what an agent does not need:
emailis alwaysnull;emailDomainis kept (a throwaway domain is a spam signal; the address is not).personin the detail response omitsemailtoo.qmatchesfullName,slug,bioand the email domain only.- Rows and the GitHub re-probe are the same as for staff; the probe is bounded the same way.
An agent's vote is a staged ruling: it acts at once (a spam vote hides
the member), it is labeled as an agent's everywhere, and it purges after three
days only if no human has voted in the meantime. The rules, checked inside the
write mutex (the check, the commit and the update of the in-memory state that
the next write's checks read all happen under one lock, so a vote queued
behind another vote or a purge sees its result):
- Humans are final. An agent may not vote on a member any human has voted
on (on this account: Votes belong to one account):
409 human_verdict_exists. Its earlier vote on that member stays as the record and has no effect. - Only ordinary members. The member must be
accountLevel = userand not an agent account:409 protected_account/422. - Only new members, for
spam. Aspamvote needs the member'screatedAtwithin the last 30 days:409 member_too_old. Long-standing members are a human's call.legitvotes have no age limit. - Volume. At most 20
spamvotes per agent in any rolling 24 hours, counted from the agent's own records'evaluatedAt(a re-vote counts again):429 rate_limitedwithRetry-After. The general per-account write cap in conventions.md applies on top. - Attribution. The commit is authored by the agent account
(
<agent fullName> <<agentSlug>@users.noreply.codeforphilly.org>), subject<agentSlug>: POST /api/admin/members/<slug>/vote(orDELETE), with the usual trailers plusActor-Kind: agentandGrant-Id: <grant id>(storage.md → Commit message shape).Actor-Account-Levelisuser.
A staged vote is the right tool when the member has not posted anything
spammy: a business or site name as the member name, a throwaway GitHub
account. When the member has posted spam, the agent purges instead
(Agent purges); if it holds no purge scope it votes spam
(which hides the spam at once) and leaves the purge to staff.
Agent only (agent:moderation:purge). Purges a member who has posted spam
— the account itself put spam content on the site — at once, without the
three-day wait. The agent names the record that is the spam and says why; the
site verifies the record is the member's and that the
agent purge rules hold, then runs the same cascade as an
administrator's purge
(person-lifecycle.md → Purge).
{
"reason": "Signed up under a business name; 90 s later set a bio advertising that business, with a link.",
"flags": ["bio-promo-link", "business-name", "github-new-account"],
"evidence": [
{ "record": "person", "field": "bio" }
],
"dryRun": false
}-
reason— required, 1–1000 chars, no URL (422,reason: a purge must not republish the spam link in a permanent public commit; cite the record instead) and no control characters, line breaks included (422,reason: the reason is the commit body, and a line break could forge a trailer). A URL is any of:- a scheme,
https://…or anyname://, including the defangedhxxp://andhxxps://; www.at the start of a word;- a bare domain: labels joined by dots with word characters on both sides of
each dot, ending in a top-level domain of two or more letters that is
either lowercase (
cheap-pills.shop) or a common TLD in any case (Cheap-Pills.SHOP); - a defanged dot between word characters:
[.],(.),{.},[dot],(dot).
Ordinary prose passes:
e.g.,i.e.,U.S., a sentence that runs into the next without a space (spam.Then), a version number. - a scheme,
-
flags— 0–10 tags, as on a vote. -
evidence— 1–5 items, each naming one record the member authored and, where the record has several text fields, which one:recordIdentified by The member's when fieldperson(the member) always bio(required)projectproject(slug)createdByIdis the membertitle|summary|overview(optional)project-updateproject,numberauthorIdis the member— project-buzzproject,slugpostedByIdis the member— help-wanted-roleproject,roleIdpostedByIdis the member— Each identifier must be well formed before anything is looked up:
projecta project slug,sluga buzz slug (slug-handles.md),roleIda UUID,numbera positive integer; otherwise422 evidence_invalidnaming the item.The site checks that each record exists, is the member's, and that the cited field (or the record's body) is not empty; otherwise
422 evidence_invalidnaming the item. It does not judge whether the content is spam: that is the agent's call, and the reason and evidence make it reviewable. The member's name is not evidence of posting: a business name with nothing posted is a vote, not a purge. -
dryRun— run every check and return what would be deleted; write nothing.
{
"success": true,
"data": {
"purge": { …the spam-purges record… },
"deleted": { "projects": [ { "slug": "…", "title": "…" } ], "memberships": 1, "updates": 0, "buzz": 0, "helpWantedRoles": 0, "rsvps": 0 },
"dryRun": false
}
}422 validation_failed— missingreason, or one carrying a URL or a control character, bad flags, no evidence or more than 5,slugis the caller or an agent account422 evidence_invalid— an evidence item with a malformed identifier, or a record missing, not the member's, or empty409 conflict—error.codeone ofhuman_verdict_exists,legit_vote_exists,member_too_old,protected_account,established_member,shared_project,evidence_survives; see Agent purges429 rate_limited— over the daily purge allowance, withRetry-After404 not_found— slug doesn't exist, or no grant with the scope
An agent purge is immediate: no staging, no three days. That is the operator's policy for an account that has posted spam ("purge immediately"), and the reason it is fenced harder than a vote. The rules are checked inside the write mutex, in the order below, and the check, the commit and the update of the in-memory state happen under one lock: the next purge or vote, however close behind, is checked against this one's result, so concurrent purges never pass the daily allowance or purge one member twice. A dry run reads the member and runs its checks under the same lock.
- Only ordinary, signed-up members.
accountLevel = user, not an agent account, no laddrlegacyId:409 protected_account/422. - Only brand-new members.
createdAtwithin the last 7 days:409 member_too_old. Posted spam comes within minutes of sign-up (both cases this was designed against posted within two minutes); seven days covers a slow weekend. Anything older is a staged vote at most (30 days), and past that a human's call. - Humans are final. Any human vote on this account,
spamorlegit, blocks:409 human_verdict_exists. A stafflegitalways wins; a staffspamhas already hidden the member and put them on the clock, and the agent does not shorten a person's clock. - No
legitvote standing. Any agent'slegitvote on this account (its own included) blocks:409 legit_vote_exists. An agent that changes its mind clears itslegitfirst, so the record shows it did. - Not established. A member who has signed into Slack through the site
(
lastSlackSsoAtset) is past the point an agent decides alone:409 established_member. - Nothing shared. The member must not have created a project anyone else
has touched (the rule in
person-lifecycle.md → Purge),
and must not be a maintainer of any project with another member:
409 shared_project. A purge must never take a project or leave one without its maintainer on an agent's say-so. Being a plain member of someone else's project does not block. Joining is self-service (POST /api/projects/:slug/members/join, any signed-in user, no maintainer acceptance), so it carries no one else's judgment, and spam accounts join real projects to borrow their credibility. The purge removes the member's own membership and anything they posted there; the project and everyone else's records are untouched. (A membership a maintainer added would be different, but the membership record does not say who added it; the agent's own policy treats a maintainer-added membership as a reason not to act — see the plan.) - The evidence goes with the purge. Every evidence record must be one the
purge deletes (the member's own record, a project the purge takes, or an
update, buzz item or role they authored that goes with them):
409 evidence_survives. The agent may not cite content it is not removing. - Volume. At most 5 purges per agent in any rolling 24 hours, counted
from its
spam-purgesrecords:429 rate_limitedwithRetry-After. Separate from, and lower than, the 20 spam votes.
When every rule holds, in one transaction and one commit per purge (never batched, so it reverts on its own):
- the cascade in person-lifecycle.md → Purge, public and private, including avatars and other attachments of deleted records;
- the agent's
person-evaluationsrecord on the slug is written (or replaced) asverdict = "spam",flags = ["agent", "purged", …flags],reasoning = reason— the vote is the durable "why" the way a staff vote is; - a
spam-purgesrecord withby = "agent-<agentSlug>", the grant id, the reason, flags and evidence (each with its data-repo path), the commit the purge was applied on, andblocksSignIn = true.
The commit is authored by the agent account, subject
<agentSlug>: POST /api/admin/members/<slug>/purge, body the reason, and
trailers Action: person.purge, Subject-Type: person, Subject-Id,
Subject-Slug, Actor-Kind: agent, Grant-Id, Purge-Id: <spam-purges id>,
and one Evidence: <path>[#<field>] per item
(storage.md → Commit message shape).
git log --grep='^Action: person.purge$' --grep='^Actor-Kind: agent$' --all-match
lists every agent purge; git show <commit>^:<evidence path> shows the spam as
it stood.
A wrong purge is restored by hand (person-lifecycle.md → Restoring a purged member); revoking the grant stops further purges at once.
The spam-purge log: every spam-purges record,
newest first. A purged member has no roster row left, so this is where staff
see what was purged, by whom and why.
| Param | Type | Notes |
|---|---|---|
by |
string | agent | human | scheduled, or one evaluator (agent-<slug>, human-<slug>). |
slug |
string | Records for one slug. |
since |
ISO date | purgedAt on or after. |
blocking |
bool | Only records whose sign-in block is in force. |
page, perPage |
int | Default perPage = 50, max 200. |
Each item is the record plus byRef (its by resolved: { kind, slug, fullName }; by itself stays the stored string), each evidence item's url
(the record as it stood, on the data repo's GitHub at beforeCommit), and
commitUrl (the history of the record's own path, whose first entry is the
purge commit).
Staff. Lifts the record's sign-in block: sets unblockedAt = now() and
unblockedBySlug; the record itself stays. { "reason": "optional, ≤ 1000" }.
200 { purge }; unblocking an unblocked record is a no-op. Committed as the
caller with Action: spam-purge.unblock. Used when a purge was wrong (with the
restore) or a person asks to start over.
- behaviors/spam-exclusion.md — the
person-evaluationsrecord shape, why human votes live onpublished, and how the offline pipeline consumes them. - behaviors/person-lifecycle.md — the deactivate side effect and its reversal rule.
- api/people.md —
deactivate,reactivate,purge, and the staff-only fields reused here. - api/agents.md — agent accounts, grants, and withdrawing an agent's staged votes.
- api/auth.md — the GitHub callback refuses a GitHub account a
spam-purgesrecord blocks.
Administrator only (404 for anyone else, like the rest of /api/admin).
Deletes the private data of every person id the private store knows that has
no people record: private profile, legacy password, password-reset tokens,
account-claim requests naming them, and session metadata — what
Purge
now deletes in its own transaction, for everyone purged or pruned before it
did.
{ "apply": false }apply is required: false only counts; true deletes and returns the same
counts. Run under the write lock, so a member mid-sign-up (private profile
written, public record not yet) is never counted.
{ "success": true, "data": { "applied": false, "orphanPeople": 27, "profiles": 27, "legacyPasswords": 0, "claimRequests": 0, "sessions": 0 } }Counts only — never emails, names or ids. The private store is not versioned,
so apply: true cannot be undone; run false first.