# Operating the moderation queue Administrator-only. This is the operational manual for the queue at `/admin/moderation`, and the evidence base for a compliance-improvement plan if a store ever asks for one. ## Daily loop 1. Open `/admin/moderation`. The default tab is **Open**, sorted oldest first. 2. Read the SLA badge in the header. It shows the age of the oldest unresolved report against `moderation_sla_hours` (currently {{ moderation_sla_hours() }}). Green means every open report is inside the window; red means at least one is over, and the count of breaches is shown. 3. Work the oldest first. Anything at `critical` severity jumps the queue: those are the sexual, exploitative, self-harm and illegal categories, plus filter failures. 4. Open a report. It renders the reported item in place, the author, the number of distinct reports on that target, and every prior decision against that author. 5. Decide. Every decision writes a `moderation_actions` row, an audit event, and a notification to the affected user stating what was decided and why. ## Severity ladder | Severity | Assigned to | Handling | |---|---|---| | `info` | Spam | Batch it; low harm | | `warn` | Everything else by default | Normal queue order | | `critical` | Sexual, exploitative, self-harm, illegal, and any filter failure | Immediately, ahead of the queue | ## The state machine `open` -> `acknowledged` -> `actioned` or `dismissed`. **Escalate** raises severity to `critical` and returns the report to `acknowledged` for a second opinion rather than resolving it. Resolution is a single atomic conditional update: if two administrators decide the same report simultaneously, exactly one succeeds and the other is answered 409. Never work around that by re-opening and re-deciding - re-read the report first. ## Choosing a sanction | Situation | Sanction | |---|---| | First offence, low harm, plausibly accidental | Remove content, reason stated | | First offence, deliberate | Remove content plus a warning | | Repeat offence, or harassment of a person | Suspend, 24 to 168 hours | | Sexual content involving a minor, credible threat, or coordinated abuse | Ban immediately, then preserve evidence | | Report is not a violation | Dismiss - the reporter is told, and that is not a penalty for reporting | State the reason in the field that is shown to the user. A decision with no reason is a decision the user cannot learn from and cannot dispute. ## What you cannot do You cannot action an administrator who registered before you. The attempt is refused and audited with `result="denied"`. This is enforced server-side, so it also binds the assistant. You cannot remove a direct message, an account, a workspace, a poll or assistant output as *content* - those have no removal path. Act on the account instead. ## Filter tuning `moderation_filter_mode` at `/admin/settings` selects the platform posture: | Mode | Effect | |---|---| | `off` | Classification does not run | | `label` | Classification only labels maturity, never reports | | `review` | Default. A match publishes and raises a system report | | `block` | A match is refused at creation | Move to `block` only with evidence from the queue. On a developer platform a blanket block produces false positives on security research and error messages, which is why `review` is the default. Watch the volume of `origin=filter` reports for a week before changing it. ## Evidence for a compliance plan Everything a reviewer or regulator can ask for is already queryable: - **Throughput and latency**: `content_reports.created_at` versus `resolved_at`. - **Backlog and SLA**: the queue's own SLA snapshot, and the count of open reports older than the window. - **Decisions and their reasons**: `moderation_actions`, one row per decision, linked to its report. - **Statements of reasons**: the moderation notification sent to each affected user. - **Immutable trail**: the `moderation` category in the audit log at `/admin/audit-log`. ## App review checklist The operator transcribes these into the store listing; every answer is a fact about the running platform, not a promise. **Review account.** Provision a normal member account for the reviewer and hand over its credentials in the review notes. Then seed it so both safety controls can actually be exercised: it must see content authored by **someone else** (so the Report and Block controls are present - both hide on your own content), and it must not be an administrator (so the moderation queue is correctly inaccessible). Check `registration_open` is `1` before submitting, or a reviewer who loses the account cannot make another one. **Review notes.** State, in this order: where the report control is (every content action bar); where blocking is (the same bar, and the profile); that reports are answered within `moderation_sla_hours`; that the terms and community guidelines are linked from the footer of every page and from the signup form; that account deletion is self-service at `/profile/{username}/delete`; and that **all user code executes on DevPlace servers in containers, never on the device** - the client only ever renders results, and nothing downloads or alters executable code. **Age-rating questionnaire.** The four answers that matter are all yes, and each has an artifact: moderation systems (`/admin/moderation`), content filtering (the classifier at the five choke points), reporting tools (`POST /reports/{target_type}/{target_uid}`), blocking (`/block/{username}`). The minimum age is `moderation_minimum_age`; user-generated content is unrestricted-web-access-free (there is no in-app browser) but does include user-published workspaces reachable at `/p/<slug>`, indexed at `/workspaces/index`. **Privacy declarations.** Declare the categories the [Privacy Policy](/docs/privacy.html) lists - contact info, user content, identifiers, usage data, diagnostics - as **linked to the user** and **not used for tracking**. There is no third-party analytics SDK, no advertising, and no cross-app or cross-site tracking anywhere in the codebase, so no tracking declaration applies. The one third-party processor of user content is the AI provider named in `ai_third_party_provider`, and it receives nothing without a granted `ai_third_party` consent. **Keep them untriggered.** Adding social login, an in-app purchase path, purchasable randomness, advertising, or cross-app tracking each creates a new mandatory obligation. If one is added, its obligation ships in the same change. ## Reversal A wrong removal is undone with the **Restore content** decision, or from `/admin/trash`, which restores the whole deletion event under one stamp. Nothing a moderator removes is destroyed until it is purged. ## Acceptance convergence on a test instance An instance kept production-identical for extended manual testing otherwise stops the tester at the same acceptance dialogs forever. The **Acceptance convergence** background service grants every policy agreement to every account that has not declined it. It is administrator-only, **off by default**, and **never appropriate on a real production host** - there is no environment detection anywhere in it, deliberately, so the only control is the operator's own judgement. **Enable it.** 1. Open `/admin/services/acceptance`, Configuration tab, and switch on only the agreements the test run needs. There is one switch per agreement type, so a single policy can be converged while the rest stay pending. 2. Leave **Dry run** on. Start the service, then use **Run now**. Read the Logs tab and confirm the accounts listed are the ones expected. Nothing has been written yet. 3. Switch Dry run off. The next run converges them, within five minutes or immediately with **Run now**. **Test a refusal path.** Withdraw the consent from your own profile's privacy tab. The consent ledger is the decline register: the service only ever grants, so a withdrawal is permanent and no number of intervals will undo it. Grant it again from the same tab to rejoin the convergence. **Test the terms gate.** Withdraw your own Terms of Service consent, then bump `terms_version` at `/admin/settings`. Every other account converges; you stay gated and can exercise `/auth/accept-terms`, the in-place acceptance dialog and the JSON refusal as often as you like. **Test a partial state.** Enable Terms of Service and leave Privacy Policy off. Accounts pass the write gate but still show the privacy policy as unaccepted on their privacy tab. **Turn it off.** Stop the service, or switch off the individual agreement. Nothing already written is reverted, and nothing should be: those accounts are in exactly the state a real population would have reached. Every convergence is in the audit log under `terms.accept` and `consent.grant` with actor kind `service`, which is how you tell a converged acceptance from a human one.