stapel-moderation
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.2.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_sanctionsmust be scheduled — otherwise a thirty-day suspension quietly stops being enforced after two hours while the row still readsactive.W004says 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stapel_moderation-0.2.0.tar.gz.
File metadata
- Download URL: stapel_moderation-0.2.0.tar.gz
- Upload date:
- Size: 161.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1273b5399552f4af65f7c23e01394c2947b2164109f4fea7ec500a2f7cdabd98
|
|
| MD5 |
a95835f7cb740e1ab63219e35fe5b68d
|
|
| BLAKE2b-256 |
6d166c2ec7f9623e80819d2c8b232709bf863a8ccbac0925fccd34170eaadd70
|
Provenance
The following attestation bundles were made for stapel_moderation-0.2.0.tar.gz:
Publisher:
publish.yml on usestapel/stapel-moderation
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stapel_moderation-0.2.0.tar.gz -
Subject digest:
1273b5399552f4af65f7c23e01394c2947b2164109f4fea7ec500a2f7cdabd98 - Sigstore transparency entry: 2581140451
- Sigstore integration time:
-
Permalink:
usestapel/stapel-moderation@a4c232b23fd75262373385661ac4e568e1ba27e1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/usestapel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a4c232b23fd75262373385661ac4e568e1ba27e1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file stapel_moderation-0.2.0-py3-none-any.whl.
File metadata
- Download URL: stapel_moderation-0.2.0-py3-none-any.whl
- Upload date:
- Size: 154.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c31220b88e8631a87f8ba034debe7eaf3f8b8551b3547be36dcdfd16f2d9b2ca
|
|
| MD5 |
93685f5354eef14cb819d9537eb5da6c
|
|
| BLAKE2b-256 |
3e580624c8d15c20be3e72c545858c95bb7b4f48d77cece1bbe3ab861ac220e8
|
Provenance
The following attestation bundles were made for stapel_moderation-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on usestapel/stapel-moderation
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stapel_moderation-0.2.0-py3-none-any.whl -
Subject digest:
c31220b88e8631a87f8ba034debe7eaf3f8b8551b3547be36dcdfd16f2d9b2ca - Sigstore transparency entry: 2581140463
- Sigstore integration time:
-
Permalink:
usestapel/stapel-moderation@a4c232b23fd75262373385661ac4e568e1ba27e1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/usestapel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a4c232b23fd75262373385661ac4e568e1ba27e1 -
Trigger Event:
push
-
Statement type: