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:

  1. content.create_content_item - posts, projects, gists, news, quizzes
  2. content.create_comment_record
  3. content.edit_content_item and content.edit_comment_record
  4. services/messaging/persist.persist_message - both the HTTP and WebSocket DM paths
  5. routers/profile/index.update_profile and models.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, not block. 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. Only ALWAYS_BLOCK_CATEGORIES (sexual, exploitative) refuse outright.
  • The technical-context discount only ever lowers a score. rules.TECHNICAL_CONTEXT matches security-research vocabulary and subtracts from weapons/violence/illegal weights. Never make it raise a score; a property test asserts the direction.
  • The filter fails to review, never to allow. classify wraps _classify in a try/except that returns verdict="review", failed=True with the error in detail, and screening.record escalates a failed classification to a critical report. 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-band token_id/token_key, never require_user. routers/devrant/_shared.resolve_actor(request, params) wraps tokens.resolve_user with refuse_suspended and is what every devRant handler calls. The one deliberate exception is DELETE /api/users/me, which calls resolve_user directly 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 to WS /messages/ws. _ws_may_write(user) checks suspension_active and needs_acceptance at connect and closes 1008, mirroring the guest branch. refuse_suspended itself cannot be reused there - it reads request.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:

  1. claim_deletion - one atomic UPDATE users SET deletion_requested_at = :stamp WHERE uid = :uid AND COALESCE(deletion_requested_at, '') = '', decided on rowcount. The COALESCE is load-bearing: the column is NULL on rows that predate it, and NULL = '' is NULL, not true. Sixteen concurrent processes produce exactly one cascade.
  2. revoke_sessions - sessions, access tokens and devRant tokens.
  3. cascade - OWNED_TABLES (an explicit, reviewed registry of table+column pairs) plus CHILD_TABLES (rows owned through a parent), all under the one shared stamp, so /admin/trash restores or purges the whole event atomically.
  4. anonymise - tombstones the username and clears every field in ANONYMISED_FIELDS. From the user's and everyone else's point of view the account is gone the moment they confirm.
  5. purge_due - after account_deletion_grace_hours, purge_event(stamp) plus a hard delete of the tombstone row. Run by ModerationService and by devplace 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.

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 -> requires ai_third_party consent, 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 in resolve_object_url, and include _report_button.html in its action bar. The registry test enforces the first two, the e2e coverage test the third.
  • A new filter category: add rules to rules.py and the reason key to REPORT_REASONS; a unit test asserts every rule's category is a real reason.
  • A new consent: add it to CONSENT_KINDS and 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.