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).

```python
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.

## 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** -> 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:

```json
{"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.
