Compare commits

..

4 Commits

7 changed files with 551 additions and 162 deletions
@@ -0,0 +1,329 @@
"""trust & safety: reports, moderation_actions, appeals + user role/ban/suspend
Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_trust_safety"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create trust & safety tables and extend users table."""
bind = op.get_bind()
# ── ENUM types ──────────────────────────────────────────────────────
# user_role
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(bind, checkfirst=True)
# report_target_type
report_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
)
report_target_type.create(bind, checkfirst=True)
# reason_category
reason_category = postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
)
reason_category.create(bind, checkfirst=True)
# report_status
report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status.create(bind, checkfirst=True)
# resolution
resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution"
)
resolution_enum.create(bind, checkfirst=True)
# moderation_action_type
moderation_action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
)
moderation_action_type.create(bind, checkfirst=True)
# moderation_target_type
moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="moderation_target_type"
)
moderation_target_type.create(bind, checkfirst=True)
# appeal_status
appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status.create(bind, checkfirst=True)
# ── Extend users table ──────────────────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role"),
nullable=False,
server_default="user",
),
)
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# ── reports table ───────────────────────────────────────────────────
op.create_table(
"reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
),
nullable=False,
),
sa.Column(
"details", sa.Text(), nullable=True,
),
sa.Column(
"status",
postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
),
nullable=False,
server_default="open",
),
sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution"
),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
onupdate=sa.func.now(),
nullable=True,
),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint(
"reporter_id", "target_type", "target_id",
name="uq_reporter_target",
),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
op.create_index(
op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False
)
op.create_index(
op.f("ix_reports_status"), "reports", ["status"], unique=False
)
# ── moderation_actions table ────────────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="moderation_target_type",
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=True),
sa.Column(
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["report_id"], ["reports.id"], ondelete="SET NULL"),
)
op.create_index(
op.f("ix_moderation_actions_id"),
"moderation_actions",
["id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_moderator_id"),
"moderation_actions",
["moderator_id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_target_user_id"),
"moderation_actions",
["target_user_id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_report_id"),
"moderation_actions",
["report_id"],
unique=False,
)
# ── appeals table ───────────────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderation_action_id", sa.Integer(), nullable=False
),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
),
nullable=False,
server_default="pending",
),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["moderation_action_id"],
["moderation_actions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["reviewed_by"], ["users.id"], ondelete="SET NULL"
),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
op.create_index(
op.f("ix_appeals_moderation_action_id"),
"appeals",
["moderation_action_id"],
unique=False,
)
op.create_index(
op.f("ix_appeals_user_id"),
"appeals",
["user_id"],
unique=False,
)
op.create_index(
op.f("ix_appeals_status"),
"appeals",
["status"],
unique=False,
)
def downgrade() -> None:
"""Remove trust & safety tables and revert users table."""
op.drop_index(op.f("ix_appeals_status"), table_name="appeals")
op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_report_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_status"), table_name="reports")
op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports")
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="resolution").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
+4 -1
View File
@@ -2,5 +2,8 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.report import Report
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
__all__ = ["User", "Session", "World", "Asset"]
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
+47
View File
@@ -0,0 +1,47 @@
"""Appeal model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True)
moderation_action_id = Column(
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
reason = Column(Text, nullable=False)
status = Column(
ENUM(AppelStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+63
View File
@@ -0,0 +1,63 @@
"""ModerationAction model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ModerationActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
target_type = Column(
ENUM(ModerationTargetType, name="moderation_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=True)
action = Column(
ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", foreign_keys=[report_id])
appeals = relationship("Appeal", back_populates="moderation_action", cascade="all, delete-orphan")
+88
View File
@@ -0,0 +1,88 @@
"""Report model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ReportTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
FALSE_POSITIVE = "false_positive"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
target_type = Column(
ENUM(ReportTargetType, name="report_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
nullable=True,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
__table_args__ = (
UniqueConstraint("reporter_id", "target_type", "target_id",
name="uq_reporter_target"),
)
+20 -1
View File
@@ -1,13 +1,20 @@
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class UserRole(str, enum.Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -17,12 +24,24 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
Enum(UserRole, name="user_role"),
nullable=False,
server_default="user",
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter")
reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user")
moderation_actions_as_moderator = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
moderation_actions_as_target = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
appeals = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
@property
def is_adult(self) -> bool:
-160
View File
@@ -1,160 +0,0 @@
# Trust & Safety Operations — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `trust_safety`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL (+ Redis available, unused)
> **Related:** `content_moderation.md` (M5 — content rating/flagging at upload time),
> `social.md` (chat + report-message), `app/dependencies.py` (JWT auth)
## 1. Overview
This feature is the **enforcement workflow** for content/behavior that is
**already live** and has been reported by a real user. M5
(`content_moderation.md`) covers rating/flagging at authoring/upload time; this
feature covers what happens **after** something is live and someone reports it:
1. **Reporting** — report a user, an experience (world/asset), or a chat
message with a reason and optional detail.
2. **Moderation queue** — staff review incoming reports, see report + target
context, and resolve them.
3. **Ban / suspension** — apply a temporary suspension or permanent ban to a
user, with enforcement at the auth boundary.
4. **Appeals** — a sanctioned user can appeal; staff can uphold or overturn.
The userbase being 18+ does **not** shrink the moderation need — harassment,
hate speech, spam, griefing, and CSAM/policy violations still require real
enforcement. Safety is a **blocking requirement**, not an add-on.
## 2. Existing constraints (do not re-build)
- **Auth:** JWT bearer; `get_current_user` / `CurrentUser` in
`app/dependencies.py`. Every new endpoint reuses it.
- **DB:** async SQLAlchemy + Alembic. Current head is `0002_add_date_of_birth_to_users`.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). There
is no `role` field yet, and no chat/message table yet (social.md is planned,
not built).
- **Redis:** configured (`REDIS_URL`) but unused; not required by this feature.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no report/chat UI yet.
## 3. Key design decisions
| Concern | Decision |
|---|---|
| Report target addressing | Generic `target_type` + `target_id` (string) so user/world/asset/message all flow through one report path; router resolves target existence via a small registry |
| Report reasons | Fixed enum `reason_category` (harassment, hate_speech, sexual_content, violence, spam, self_harm, other) + free-text `details` |
| Staff roles | Add `role` enum on `User` (`user` / `moderator` / `admin`); `RequireStaff` dependency gates the queue/action/appeal-review endpoints |
| Enforcement | Denormalized `banned_until` / `suspended_until` timestamps on `User` (kept in sync by moderation actions); `get_current_user` rejects sanctioned users with `403` so enforcement applies to **all** existing endpoints |
| Ban vs. suspension | Suspension = temporary (`expires_at`); ban = permanent (`expires_at IS NULL`). Both are rows in `moderation_actions` for auditability |
| Appeals | Appeal references the specific `moderation_action`; approving an appeal revokes the action and clears the user's sanction |
| Audit | Every moderation action is an append-only row with `moderator_id`, `reason`, `created_at`, `revoked_at`; reports record `resolved_by` + `resolution` |
## 4. Data model (target shape)
```
users
+ role ENUM(user, moderator, admin) default 'user'
+ banned_until TIMESTAMPTZ NULL
+ suspended_until TIMESTAMPTZ NULL
reports
id, reporter_id FK users, target_type ENUM(user,world,asset,chat_message),
target_id VARCHAR(64), reason_category ENUM(...), details TEXT,
status ENUM(open,in_review,resolved,dismissed) default 'open',
resolved_by FK users NULL, resolution ENUM(...) NULL,
created_at, updated_at, resolved_at
moderation_actions
id, moderator_id FK users, target_user_id FK users,
target_type ENUM(user,world,asset,chat_message), target_id VARCHAR(64),
action ENUM(warn,suspend,ban,restrict,remove,restore),
reason TEXT, report_id FK reports NULL,
expires_at TIMESTAMPTZ NULL, revoked_at TIMESTAMPTZ NULL, created_at
appeals
id, moderation_action_id FK moderation_actions,
user_id FK users, reason TEXT,
status ENUM(pending,approved,rejected) default 'pending',
reviewed_by FK users NULL, reviewed_at TIMESTAMPTZ NULL, created_at
```
## 5. User stories
### US-1 Reporting
As a player, I can report a user, an experience (world/asset), or a chat
message, choosing a reason and adding optional detail.
**Acceptance criteria**
- Report requires a valid target of the matching type (404 otherwise) and a
reason from the fixed category set.
- Duplicate reports from the same reporter for the same target are coalesced
(409) rather than creating unbounded duplicates.
- A report cannot be filed by a banned/suspended user (enforced by existing auth).
- Each report stores reporter, target, reason, and created_at; status starts `open`.
### US-2 Moderation queue & review
As a moderator, I can list open reports (filterable by type/status/reason),
view a report with its target, and resolve it with an action.
**Acceptance criteria**
- Queue is staff-only (`RequireStaff`); non-staff get 403.
- List supports status/type/reason filters + pagination (offset/limit).
- Resolving sets `status`, `resolution`, `resolved_by`, `resolved_at` and
(where applicable) records a `moderation_actions` row.
### US-3 Ban / suspension enforcement
As a moderator, I can suspend a user temporarily or ban them permanently, and
the sanction blocks the user from authenticated endpoints immediately.
**Acceptance criteria**
- Suspension sets `suspended_until`; ban sets `banned_until` (permanent). Warn
records an action without blocking.
- `get_current_user` returns `403` for any user past a live sanction.
- Expired suspensions no longer block (time-based, no cron required).
- Removing a ban/suspension is itself a recorded `restore` action.
### US-4 Appeals
As a sanctioned user, I can appeal an active ban/suspension; as a moderator I
can approve (overturn) or reject (uphold) it.
**Acceptance criteria**
- A user can submit exactly one open appeal per sanction.
- Appeal is tied to the specific `moderation_action`.
- Approving revokes the action and clears the user's sanction; rejecting
upholds it. Both record reviewer + timestamp.
## 6. Task breakdown
Right-sized to ~1–3 files / one coherent piece of functionality. Task IDs are
dependency order.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Moderation data model + Alembic migration (reports, moderation_actions, appeals, user `role`/`banned_until`/`suspended_until`) | US-1..4 | — |
| 2 | Staff authorization + sanction enforcement dependency (`RequireStaff`; extend `get_current_user` to reject sanctioned users) | US-2, US-3 | 1 |
| 3 | Report submission endpoints (report user / experience / chat message) | US-1 | 1 |
| 4 | Moderation queue + review endpoints (list, detail, resolve→action) | US-2 | 1, 2 |
| 5 | Ban/suspend/restore action application + user-sanction sync | US-3 | 2, 4 |
| 6 | Appeals endpoints (submit, list, review → uphold/overturn) | US-4 | 1, 5 |
| 7 | Integration tests: report → review → ban → appeal → overturn | US-1..4 | 3, 4, 5, 6 |
## 7. Phasing
- **Phase 1 (data + enforcement core):** tasks 1–2. Land the schema and make
sanctioned users actually unable to act — highest value.
- **Phase 2 (report → review → action):** tasks 3–5. The moderation loop.
- **Phase 3 (appeals):** task 6.
- **Phase 4 (verification):** task 7 integration tests.
## 8. Out of scope for this feature
- **Client (Godot) report/appeal UI** — no chat/social UI exists yet; report
endpoints are consumed once `social.md` US-6 lands. Client is a follow-up.
- Automated content scanning / hash detection (covered by M5 /
`content_moderation.md`).
- Third-party moderation/CSAM-scanning vendors.
- Rate limiting of report submission (Redis is available; add a token-bucket
dependency later if abuse is observed).
- Voice chat moderation.
- Per-jurisdiction legal-takedown workflows (DMCA etc.).