Metadata-Version: 2.4
Name: stapel-moderation
Version: 0.1.0
Summary: Target-generic moderation queue: reports, verdicts, sanctions and appeals for the Stapel framework
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-moderation
Project-URL: Repository, https://github.com/usestapel/stapel-moderation
Project-URL: Documentation, https://github.com/usestapel/stapel-moderation#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-moderation/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-moderation/issues
Keywords: django,stapel,moderation,trust-and-safety,dsa
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<1.0,>=0.32.0
Provides-Extra: all
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-moderation

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-moderation/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-moderation/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-moderation?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-moderation)
[![pypi](https://img.shields.io/pypi/v/stapel-moderation?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-moderation/)
[![downloads](https://static.pepy.tech/badge/stapel-moderation/month)](https://pepy.tech/project/stapel-moderation)
[![python](https://img.shields.io/pypi/pyversions/stapel-moderation?logo=python&logoColor=white)](https://pypi.org/project/stapel-moderation/)
[![license](https://img.shields.io/github/license/usestapel/stapel-moderation)](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)

> The fleet's single producer of moderation verdicts: one target-generic queue over listings, reviews, chat messages and profiles, keyed by an opaque (target_type, target_key) and driven by a host-registered policy per type. One Case per target however many people complain, one status vocabulary in the whole module, an append-only Verdict and an append-only CaseEvent audit trail whose mutations are FORBIDDEN by mandate declaration. Screening is a comm-Task (deterministic rules, then schema-constrained llm.complete) with a closed hold-for-a-human default when the automation cannot answer. Resolution ACTS on the target by emitting moderation.completed, which stapel-listings and stapel-reviews already consume. Sanctions carry a kind, a scope, a reason, a clock, an appeal and an audit trail, and they bite through core's cross-service user blacklist — the hook every request path already checked and nobody had ever called. Plus DSA artefacts generated from the registries: a public policy disclosure, a statement of reasons on every takedown, an acknowledgement to every complainant, and an internal appeal heard by a different moderator.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-moderation
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.1.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13) |
| HTTP operations | 18 |
| Config axes | 11 |
| Usage surface | 56 |
| Extension points | 6 |
| Error codes | 69 |
| Fleet dependencies | [`stapel-agent`](https://github.com/usestapel/stapel-agent) (optional) · [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-cdn`](https://github.com/usestapel/stapel-cdn) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-notifications`](https://github.com/usestapel/stapel-notifications) (optional) |

## Documentation

[OpenAPI](https://github.com/usestapel/stapel-moderation/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-moderation/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)

## What this is

One **moderation queue** for everything a product publishes. A listing, a
review, a chat message and an avatar are the same kind of work item here, and
one moderator console works all of them.

Three decisions carry the whole design.

**The unit of work is a Case, not a complaint.** Forty people reporting one
listing produce forty `Report` rows hanging off **one** `Case` with
`report_count = 40`. The system this replaced kept forty queue rows, which is
why its moderators saw the same listing forty times and why its queue page
read two whole tables into memory before it could show anybody anything.

**There is one status vocabulary in the module: `Case.state`.** A `Report` has
no status of its own and inherits its case's; a `Verdict` has none because it
is an append-only fact; a `Sanction` has an orthogonal lifecycle that never
mixes with a case state. The predecessor had three near-identical unrelated
status enums plus two more free copies in a serializer and an HTML template,
and they could and did disagree.

**Moderation never calls a host back to mutate it.** Resolving a case emits
`moderation.completed`, and the target module applies the verdict to itself —
[stapel-listings](https://github.com/usestapel/stapel-listings) 0.4.0 and
[stapel-reviews](https://github.com/usestapel/stapel-reviews) 0.2.0 already
consume it. The action IS the fact, so a new kind of moderated thing is a
registry entry plus a consumer in its own repository, never a branch in here.

## Quick start

```bash
pip install stapel-moderation
```

```python
INSTALLED_APPS = [
    # ...
    "stapel_moderation",
]

# urls.py
path("moderation/", include("stapel_moderation.urls"))   # -> /moderation/api/v1/...

# Declare what may be moderated. The module ships knowing NO target types.
STAPEL_MODERATION = {
    "TARGET_TYPES": {
        "listing": {
            "intake_events": ["listing.submitted"],
            "id_field": "listing_id",
            "content_function": "listings.moderation_content",
            "notification_types": {"content_blocked": "listing_blocked"},
        },
        "review": {
            "id_field": "review_id",
            "content_function": "reviews.moderation_content",
        },
    },
}

# Moderator rights are staff roles + the core mandate. No allow-list.
STAPEL_ACCESS = {"ROLES": {
    "moderator": {"clearance": "low", "apps": {"moderation": "mid"}},   # read the queue
    "ts_lead":   {"clearance": "mid", "apps": {"moderation": "high"}},  # decide and sanction
}}

# The scheduled half. Without it, long suspensions stop being enforced.
from stapel_moderation.tasks import get_moderation_beat_schedule
CELERY_BEAT_SCHEDULE = {**get_moderation_beat_schedule()}
```

## How a case moves

```
listing.submitted ──▶ open_case ──▶ comm-Task "moderation.screen"
                                          │
                     ┌────────────────────┼────────────────────┐
                     ▼                    ▼                    ▼
                 rules hit            llm.complete         unavailable
              (no LLM billed)      (schema-constrained)   (retry ×3, then
                     │                    │                ON_SCREENING_FAILURE)
                     └────────┬───────────┘                    │
                              ▼                                ▼
                    approved / rejected              needs_review ──▶ human queue
                              │                                          │
                              ▼                                          ▼
                    emit moderation.completed  ◀──────────────  moderator verdict
                              │                                  (+ optional sanction)
                              ▼
                 the target module blocks itself
```

## The switches that ship closed

Every setting that trades safety for availability is off by default, and the
three that matter print a startup warning when a host turns them on — because
each one is *invisible at runtime*, and the predecessor system had two of them
silently enabled for years.

| setting | default | what opening it costs |
|---|---|---|
| `ON_SCREENING_FAILURE` | `"hold"` | `"approve"` publishes content nobody screened; `"reject"` removes content nobody screened. Either prints `W001`. |
| `AUTO_RESOLVE_STALE_QUEUE` | `None` | A number makes unreviewed cases approve themselves on a clock. Prints `W002`. |
| `ALLOW_ANONYMOUS_REPORTS` | `False` | Requires a contact address and a captcha; without one, a complaint flood is a denial-of-service against the queue. Prints `W003`. |
| `APPEAL_REQUIRES_DIFFERENT_ACTOR` | `True` | Off, the moderator who decided also hears the appeal. |

## What a ban actually does

`Sanction` is a row with a kind, a scope, a reason, a clock, an appeal and an
audit trail — not a boolean. Its teeth are `stapel-core`'s cross-service user
blacklist, which DRF authentication, the middleware (twice), channels and the
auth refresh endpoint all already check on every request, and which **had no
producer anywhere in the fleet** until this module. Deactivating the account
instead would touch no live session at all: `is_active` is only consulted when
a new token is issued.

Two operational consequences, stated rather than discovered:

- the blacklist is a cache key with a TTL, so `rearm_active_sanctions` must be
  scheduled — otherwise a thirty-day suspension quietly stops being enforced
  after two hours while the row still reads `active`. `W004` says so;
- core fails **closed** when that cache is unreachable, so a Redis outage
  locks everybody out, not just the sanctioned. That is a property of core's
  blacklist, and it belongs in the runbook.

## Notice-and-action artefacts

The compliance surface is generated from the registries, not maintained as
prose beside them:

- `GET /moderation/api/v1/policy` — public, and assembled from the reason
  registry, the rule registry and the actual screening settings, so it cannot
  describe a system other than the one running;
- every takedown carries a statement of reasons and an appeal link;
- every complainant gets an acknowledgement and, later, the outcome;
- an appeal reopens and re-decides its case rather than filing a letter — the
  one backward edge in the state machine exists for exactly that.

## The Django admin is read-only, on purpose

Moderators *are* Django staff here, so the usual "different audience" argument
does not apply. The reason is path integrity: in the predecessor, admin bulk
actions flipped report statuses through `queryset.update()` — no audit row, no
timestamp, and the reviewed content was never actually hidden. A second
resolution path existed, invisible to the audit log. Read-only registration
makes that path impossible by construction, and `CaseEvent` is declared
`@access.ops`, whose mutations are `FORBIDDEN` even for a superuser.

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE).

---

<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
