Skip to main content

stapel-moderation

CI coverage pypi downloads python license 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 — composable Django apps that deploy as a monolith or as microservices without changing module code.

Install

pip install stapel-moderation

At a glance

Fact Value
Version 0.3.0
Python >=3.11 (3.11, 3.12, 3.13)
HTTP operations 18
Config axes 11
Usage surface 59
Extension points 6
Error codes 70
Fleet dependencies stapel-agent (optional) · stapel-auth (optional) · stapel-cdn (optional) · stapel-core · stapel-notifications (optional)

Documentation

OpenAPI · capabilities.json · llms.txt (for agents)

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 0.4.0 and 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

pip install stapel-moderation
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.


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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stapel_moderation-0.3.0.tar.gz (165.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stapel_moderation-0.3.0-py3-none-any.whl (156.3 kB view details)

Uploaded Python 3

File details

Details for the file stapel_moderation-0.3.0.tar.gz.

File metadata

  • Download URL: stapel_moderation-0.3.0.tar.gz
  • Upload date:
  • Size: 165.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for stapel_moderation-0.3.0.tar.gz
Algorithm Hash digest
SHA256 f3a4ef76647c2e4c259a0af77e69a576d1109e5a4d98c22ece2c00ce30a3a724
MD5 0de1560db9cf6c257064bc11fff02ff4
BLAKE2b-256 1a3a3432ef646853c0f9b674bc62666391f3564387895504477163adb72c8047

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_moderation-0.3.0.tar.gz:

Publisher: publish.yml on usestapel/stapel-moderation

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file stapel_moderation-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for stapel_moderation-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cebdc274414cc266f997dd482866eae96f8ba6c9d3f95852303d31fc55899cf3
MD5 892274075c145341510605ba1797f5e1
BLAKE2b-256 142b8f1e9849dbbbe84b9097a562959e51a372dd00d21a5e2046852bbb27f0d0

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_moderation-0.3.0-py3-none-any.whl:

Publisher: publish.yml on usestapel/stapel-moderation

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.0

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page