This file documents the moderation subsystem (devplacepy/services/moderation/, the /reports and /admin/moderation routers, the report/maturity/consent data layer in database/moderation.py, and account deletion). Claude Code auto-loads it whenever a file under this directory is read or edited.
Why this subsystem exists
It is the safety layer an app store requires of a social platform: a filter at post time, a report control on every surface, a queue with a published response window, real enforcement, statements of reasons, consent, age gating, and self-service account deletion. The design and the requirement-to-artifact map live in appleimpl.md at the repository root.
The two load-bearing ideas
One registry, one queue. database/moderation.py REPORTABLE_TARGETS maps every externally visible surface to its table; content_reports is the single queue with two producers (members and the filter) and one state machine. Every consumer - the report route, the report partial, the Devii action, the API docs enum, the admin filter - derives from the registry, so adding a surface is one line rather than twenty.
Coverage is enforced, not remembered. tests/unit/database/moderation.py asserts that every table in SOFT_DELETE_TABLES is either in REPORTABLE_TARGETS or in the explicit, reviewed UNREPORTABLE_TABLES exclusion list (with its reason). Adding a user-generated table without classifying it fails the suite. Never widen the exclusion list to silence that test without a real reason for the entry.
Module map
| File | Owns |
|---|---|
rules.py |
The category rule set (RULES), FILTER_MODES, ALWAYS_BLOCK_CATEGORIES, the technical-context discount |
filter.py |
classify(text, mode) -> Classification, screen(values) -> Screening, resolve_verdict |
screening.py |
The choke-point API: screen_fields, refuse_if_blocked, record, and the ContentRefused exception |
queue.py |
raise_report, claim_open, set_status, escalate, record_action, list_reports, status_counts |
enforcement.py |
remove_content, restore_content, suspend_user, lift_suspension, ban_user, revoke_sessions, notify_subject (the one statement-of-reasons delivery: type moderation, target NOTICE_URL) |
deletion.py |
claim_deletion, delete_account, cascade, anonymise, due_purges, purge_due |
sla.py |
sla_hours, oldest_open, breach_count, snapshot |
service.py |
ModerationService - lock-owner housekeeping: purges due deletions, reports the SLA, exposes queue metrics |
The filter: five choke points, and why it defaults to review
classify is never called from a router. It runs through screening.screen_fields at exactly five places, because content creation on DevPlace already funnels through them:
content.create_content_item- posts, projects, gists, news, quizzescontent.create_comment_recordcontent.edit_content_itemandcontent.edit_comment_recordservices/messaging/persist.persist_message- both the HTTP and WebSocket DM pathsrouters/profile/index.update_profileandmodels.SignupForm(username)
The pattern at each is the same and the order is load-bearing: screen and refuse before the write, record after it (the report needs the target uid).
screening = screen_fields(table_name, fields)
refuse_if_blocked(screening)
...the insert...
record(screening, target_type=..., target_uid=uid, actor_uid=user["uid"], request=request)
refuse_if_blocked raises ContentRefused, handled once by the @app.exception_handler(ContentRefused) in main.py (400 + the category list for JSON, the error page for a browser). No caller catches it - that is what keeps the five choke points free of per-route error handling.
Three properties must not regress:
- The default mode is
review, notblock. A match publishes and raises a system report. This is not timidity: this is a developer platform whose members discuss exploits, malware analysis and violent subject matter as their work, and a machine that suppressed them would destroy the product. OnlyALWAYS_BLOCK_CATEGORIES(sexual, exploitative) refuse outright. - The technical-context discount only ever lowers a score.
rules.TECHNICAL_CONTEXTmatches security-research vocabulary and subtracts fromweapons/violence/illegalweights. Never make it raise a score; a property test asserts the direction. - The filter fails to
review, never toallow.classifywraps_classifyin a try/except that returnsverdict="review", failed=Truewith the error indetail, andscreening.recordescalates a failed classification to acriticalreport. A safety control that fails silently is worse than none.
moderation_filter_mode (off/label/review/block) and moderation_filter_review_score are live site_settings. resolve_verdict is monotone in the mode: strengthening the mode can never weaken a verdict, and a property test iterates the whole mode x verdict matrix.
The queue: one atomic resolution, never a check-then-act
queue.claim_open(uid, status, actor) is the only way a report becomes actioned/dismissed. It is a single conditional UPDATE ... WHERE status IN (open, acknowledged) through database.conditional_update_row, decided on the driver's real rowcount. Two administrators deciding the same report simultaneously produce exactly one moderation_actions row; the loser gets a 409. This is proven with 16 real OS processes, not threads. Never replace it with a read-then-write.
escalate is deliberately not a resolution: it raises severity to critical and returns the report to acknowledged for a second opinion.
Duplicate handling mirrors workspace_flags.raise_flag: an open report by the same reporter on the same target is updated, never duplicated; a different reporter creates a second row and the queue shows the count.
Enforcement, and what removal cannot cover
enforcement.can_remove(target_type) is the honest boundary. Content removal exists for posts, gists, projects, quizzes, news, comments, attachments and project files. It does not exist for direct messages, accounts, workspaces, polls or assistant output - those have no removal path in the data model, so the remedy is the account-level action (warn/suspend/ban). The admin UI filters the action list on this predicate and says so; do not paper over it with a silently-failing "remove".
remove_content reuses content.delete_content_item / delete_comment_record rather than re-implementing the cascade, so a moderator removal is byte-identical to an owner removal (same soft delete, same stamp, same audit).
Suspension is enforced by ONE predicate, utils.guards.refuse_suspended. It gates mutating methods only and exempts /auth, /reports, /block, /mute, account deletion and consent changes, so a suspended user can always read, always see why, always report, always withdraw a consent, and always delete their account. A user is never trapped.
Every auth resolver must reach that predicate. require_user / require_user_api cover the whole HTTP surface, but two paths authenticate on their own and would otherwise be silent bypasses - both now call the same predicate rather than re-implementing it:
- devRant (
/api) resolves in-bandtoken_id/token_key, neverrequire_user.routers/devrant/_shared.resolve_actor(request, params)wrapstokens.resolve_userwithrefuse_suspendedand is what every devRant handler calls. The one deliberate exception isDELETE /api/users/me, which callsresolve_userdirectly because it is the account-deletion path and must stay reachable.is_account_active(which the token resolver already checks) covers a ban but not a time-boxed suspension, which is why the extra call is needed. - The messages WebSocket.
@app.middleware("http")never runs for a WebSocket scope, so neither the terms gate nor the suspension gate applied toWS /messages/ws._ws_may_write(user)checkssuspension_activeandneeds_acceptanceat connect and closes1008, mirroring the guest branch.refuse_suspendeditself cannot be reused there - it readsrequest.method, which a WebSocket has no equivalent of.
Adding a new auth resolver means adding it to this list, not adding a second suspension rule.
Every per-user enforcement passes routers/admin/_shared.is_senior_admin / deny_senior (moved there from admin/users.py so the moderation router shares it), so a junior administrator can never action a senior one - server-side, therefore also binding on Devii.
Account deletion: claim, cascade under one stamp, anonymise, purge
deletion.delete_account(user) returns None when it loses the race and a result dict when it wins:
claim_deletion- one atomicUPDATE users SET deletion_requested_at = :stamp WHERE uid = :uid AND COALESCE(deletion_requested_at, '') = '', decided onrowcount. TheCOALESCEis load-bearing: the column is NULL on rows that predate it, andNULL = ''is NULL, not true. Sixteen concurrent processes produce exactly one cascade.revoke_sessions- sessions, access tokens and devRant tokens.cascade-OWNED_TABLES(an explicit, reviewed registry of table+column pairs) plusCHILD_TABLES(rows owned through a parent), all under the one shared stamp, so/admin/trashrestores or purges the whole event atomically.anonymise- tombstones the username and clears every field inANONYMISED_FIELDS. From the user's and everyone else's point of view the account is gone the moment they confirm.purge_due- afteraccount_deletion_grace_hours,purge_event(stamp)plus a hard delete of the tombstone row. Run byModerationServiceand bydevplace accounts prune.
user_consents, content_reports and moderation_actions are deliberately not in the cascade: they key on owner_id/reporter_uid, not user_uid, and the privacy policy states that the moderation and consent record is retained. Adding them to OWNED_TABLES would destroy the proof that the platform enforced its own rules.
Deletion is owner-only (_owner_only in routers/profile/delete.py) and needs the account password. An administrator removing someone uses a ban, not a deletion - they cannot know the password, and a ban is the auditable act. The devRant DELETE /api/users/me routes into this same cascade; it is no longer a deactivation.
Consent, and the one AI gate
Five consents live in user_consents (CONSENT_KINDS), append-only in effect: a withdrawal stamps the current row and writes a new one, so the history is provable. Signup grants terms, privacy and activity_recording; ai_third_party and container_credentials are never granted by default.
Changing a consent is owner-only, exactly like account deletion (_owner_only in routers/profile/delete.py, reused by routers/profile/consent.py for both POST /profile/{username}/consent and POST /profile/{username}/mature-content). An administrator reads the privacy tab of an account they moderate, but may never grant or withdraw on someone else's behalf - a consent an administrator could grant would not be a consent, and it would let an admin unlock third-party AI processing of a member's content or hand a member the mature-content reveal. The privacy tab renders the toggles only for the owner and the profile route withholds age_band/terms_*/suspended_until/suspension_reason/mature_opt_in from any other viewer in BOTH the HTML and the ProfileOut JSON (the same rule as the _ai_quota dollar fields).
The gate is at exactly one place: GatewayService.consent_denied in services/openai_gateway/service.py, checked in handle() right after resolve_owner. The split is the whole point:
- owner kind
user/admin= the call carries that user's own content -> requiresai_third_partyconsent, 403 otherwise; - owner kind
internal/key/anonymous= platform processing (news import, bots, SEO metadata) -> ungated.
ai_correction_enabled / ai_modifier_enabled survive unchanged as preferences subordinate to consent. No consumer changed and no existing preference was flipped: withdrawing consent simply makes the gateway refuse.
container_credentials gates containers/api.validate_run_as: a container configured to run as someone else would inject that person's real DEVPLACE_API_KEY into software they do not operate, so it is refused unless they granted the consent. Running a container as yourself never asks - you are the one handing over your own credential.
activity_recording gates presence.touch, checked after the per-worker throttle so the consent read costs at most one query per half-window per user. Withdraw it and you simply appear offline. The consent is managed on the profile privacy tab; no page chrome advertises its state.
Terms re-acceptance
terms_acceptance_gate in main.py sits beside the maintenance gate. It gates mutating methods only and exempts /static, /avatar, /auth, /docs, /reports, /block, /mute, /openai and account deletion. Reading, accepting and leaving are never blocked.
Every reader of a policy version uses get_setting(key, "1") or "1". This is not cosmetic: on a fresh database init_db skips the settings seed (its tables snapshot predates site_settings), so an admin settings save can insert terms_version = "", and a bare get_setting would then compare every user's "1" against "" and 403 every write on the platform. That was a real failure; keep the or "1".
The refusal is self-describing, and the client acts on it
A browser form POST is a real navigation, so the 303 to /auth/accept-terms already works with no JS. A fetch caller cannot follow that, so the JSON branch carries everything the client needs to resolve the block itself:
{"error": {"status": 403, "message": "Accept the updated Terms of Service to continue.",
"code": "terms_acceptance_required", "redirect": "/auth/accept-terms", "terms_version": "1"}}
code is the contract - the client matches on it, never on the message text. It is TERMS_ACCEPTANCE_CODE in routers/auth/terms.py, the single definition, imported by main.py and asserted by the api test. terms_version lets the dialog name the version without a second request.
The client side is static/js/TermsGate.js (app.termsGate); see devplacepy/static/js/CLAUDE.md. Do not add a second terms check, a second refusal shape, or a per-caller handler - Http routes every fetch refusal through the one gate, so a new fetch caller inherits the behaviour with no work.
Note on users.terms_version: init_db ensures the column but deliberately does not backfill it, so every account predating the trust-and-safety commit reads NULL and must accept. That is correct - a backfill would fabricate consent nobody gave - but it means the gate is the normal state for legacy accounts, not a rare edge, so the accept path must stay one click.
Maturity
content_maturity is polymorphic (target_type, target_uid), read through the batch helper get_maturity_by_targets - never per row. Absence of a row means general, so nothing needed backfilling. content.maturity_hidden(level, user) is the single predicate (also the maturity_hidden Jinja global) and _maturity_gate.html is the single interstitial; enrich_items and load_detail attach maturity so listings and detail pages both have it with one query.
Rules for extending this
- A new user-generated surface: add it to
REPORTABLE_TARGETS, make it resolve inresolve_object_url, and include_report_button.htmlin its action bar. The registry test enforces the first two, the e2e coverage test the third. - A new filter category: add rules to
rules.pyand the reason key toREPORT_REASONS; a unit test asserts every rule's category is a real reason. - A new consent: add it to
CONSENT_KINDSand enforce it at one choke point, never at N call sites. - Never add a second removal path, a second suspension check, or a second consent gate.