stapel-moderation 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. stapel_moderation-0.1.0/CONFIG.MD +126 -0
  2. stapel_moderation-0.1.0/LICENSE +21 -0
  3. stapel_moderation-0.1.0/PKG-INFO +221 -0
  4. stapel_moderation-0.1.0/README.md +193 -0
  5. stapel_moderation-0.1.0/__init__.py +48 -0
  6. stapel_moderation-0.1.0/_capabilities.py +61 -0
  7. stapel_moderation-0.1.0/_codegen.py +117 -0
  8. stapel_moderation-0.1.0/_codegen_settings.py +130 -0
  9. stapel_moderation-0.1.0/actions.py +301 -0
  10. stapel_moderation-0.1.0/admin.py +108 -0
  11. stapel_moderation-0.1.0/apps.py +41 -0
  12. stapel_moderation-0.1.0/authz.py +253 -0
  13. stapel_moderation-0.1.0/checks.py +319 -0
  14. stapel_moderation-0.1.0/codegen_urls.py +15 -0
  15. stapel_moderation-0.1.0/conf.py +175 -0
  16. stapel_moderation-0.1.0/conftest.py +327 -0
  17. stapel_moderation-0.1.0/docs/capabilities.json +612 -0
  18. stapel_moderation-0.1.0/docs/errors.json +582 -0
  19. stapel_moderation-0.1.0/docs/flows.json +1 -0
  20. stapel_moderation-0.1.0/docs/llms.txt +284 -0
  21. stapel_moderation-0.1.0/docs/schema.json +1739 -0
  22. stapel_moderation-0.1.0/dto.py +97 -0
  23. stapel_moderation-0.1.0/errors.py +132 -0
  24. stapel_moderation-0.1.0/events.py +264 -0
  25. stapel_moderation-0.1.0/functions.py +173 -0
  26. stapel_moderation-0.1.0/gdpr.py +116 -0
  27. stapel_moderation-0.1.0/migrations/0001_initial.py +193 -0
  28. stapel_moderation-0.1.0/migrations/__init__.py +0 -0
  29. stapel_moderation-0.1.0/models.py +519 -0
  30. stapel_moderation-0.1.0/notifications.py +245 -0
  31. stapel_moderation-0.1.0/presenters.py +496 -0
  32. stapel_moderation-0.1.0/projections.py +68 -0
  33. stapel_moderation-0.1.0/prompts.py +123 -0
  34. stapel_moderation-0.1.0/py.typed +0 -0
  35. stapel_moderation-0.1.0/pyproject.toml +94 -0
  36. stapel_moderation-0.1.0/registry.py +475 -0
  37. stapel_moderation-0.1.0/schemas/consumes/moderation.applied.json +24 -0
  38. stapel_moderation-0.1.0/schemas/consumes/staff.role.revoked.json +18 -0
  39. stapel_moderation-0.1.0/schemas/consumes/task.failed.json +25 -0
  40. stapel_moderation-0.1.0/schemas/consumes/user.deleted.json +15 -0
  41. stapel_moderation-0.1.0/schemas/emits/moderation.appeal.opened.json +26 -0
  42. stapel_moderation-0.1.0/schemas/emits/moderation.appeal.resolved.json +30 -0
  43. stapel_moderation-0.1.0/schemas/emits/moderation.case.opened.json +46 -0
  44. stapel_moderation-0.1.0/schemas/emits/moderation.case.queued.json +28 -0
  45. stapel_moderation-0.1.0/schemas/emits/moderation.completed.json +57 -0
  46. stapel_moderation-0.1.0/schemas/emits/moderation.report.received.json +33 -0
  47. stapel_moderation-0.1.0/schemas/emits/moderation.report.reviewed.json +31 -0
  48. stapel_moderation-0.1.0/schemas/emits/moderation.sanction.expired.json +29 -0
  49. stapel_moderation-0.1.0/schemas/emits/moderation.sanction.issued.json +61 -0
  50. stapel_moderation-0.1.0/schemas/emits/moderation.sanction.lifted.json +29 -0
  51. stapel_moderation-0.1.0/schemas/functions/moderation.case_status.json +19 -0
  52. stapel_moderation-0.1.0/schemas/functions/moderation.check_sanctions.json +19 -0
  53. stapel_moderation-0.1.0/schemas/functions/moderation.policy_disclosure.json +18 -0
  54. stapel_moderation-0.1.0/schemas/functions/moderation.sanctions_by_users.json +18 -0
  55. stapel_moderation-0.1.0/schemas/functions/moderation.sanctions_export.json +25 -0
  56. stapel_moderation-0.1.0/schemas/functions/moderation.submit.json +36 -0
  57. stapel_moderation-0.1.0/screening.py +280 -0
  58. stapel_moderation-0.1.0/serializers.py +239 -0
  59. stapel_moderation-0.1.0/services.py +1360 -0
  60. stapel_moderation-0.1.0/setup.cfg +4 -0
  61. stapel_moderation-0.1.0/stapel_moderation.egg-info/PKG-INFO +221 -0
  62. stapel_moderation-0.1.0/stapel_moderation.egg-info/SOURCES.txt +142 -0
  63. stapel_moderation-0.1.0/stapel_moderation.egg-info/dependency_links.txt +1 -0
  64. stapel_moderation-0.1.0/stapel_moderation.egg-info/requires.txt +3 -0
  65. stapel_moderation-0.1.0/stapel_moderation.egg-info/top_level.txt +1 -0
  66. stapel_moderation-0.1.0/tasks.py +389 -0
  67. stapel_moderation-0.1.0/tests/test_api.py +439 -0
  68. stapel_moderation-0.1.0/tests/test_appeals.py +185 -0
  69. stapel_moderation-0.1.0/tests/test_comm.py +317 -0
  70. stapel_moderation-0.1.0/tests/test_contract.py +291 -0
  71. stapel_moderation-0.1.0/tests/test_gdpr.py +238 -0
  72. stapel_moderation-0.1.0/tests/test_intake.py +182 -0
  73. stapel_moderation-0.1.0/tests/test_notifications.py +275 -0
  74. stapel_moderation-0.1.0/tests/test_public_api.py +55 -0
  75. stapel_moderation-0.1.0/tests/test_queue.py +282 -0
  76. stapel_moderation-0.1.0/tests/test_registry.py +300 -0
  77. stapel_moderation-0.1.0/tests/test_sanctions.py +360 -0
  78. stapel_moderation-0.1.0/tests/test_screening.py +374 -0
  79. stapel_moderation-0.1.0/translations/errors.es.json +29 -0
  80. stapel_moderation-0.1.0/translations/errors.ru.json +29 -0
  81. stapel_moderation-0.1.0/urls.py +16 -0
  82. stapel_moderation-0.1.0/urls_v1.py +89 -0
  83. stapel_moderation-0.1.0/views.py +760 -0
@@ -0,0 +1,126 @@
1
+ # CONFIG.MD — stapel-moderation
2
+
3
+ Config registry for **stapel-moderation** (`static-scaffold-and-config.md`
4
+ §2). One row per configuration key the module reads, its **source** (`env` =
5
+ the process environment / the `STAPEL_MODERATION` settings dict), what it is
6
+ for, whether it is required, and its default.
7
+
8
+ All keys are read through `moderation_settings`
9
+ (`stapel_moderation.conf.AppSettings`, namespace `STAPEL_MODERATION`).
10
+ Resolution order per key: `settings.STAPEL_MODERATION` dict → a flat Django
11
+ setting of the same name → environment variable → the default below.
12
+
13
+ Two notes on the `env` column, which is the manifest's vocabulary for "the
14
+ process environment or the `STAPEL_MODERATION` settings dict" rather than a
15
+ claim that every key is settable from a shell:
16
+
17
+ - the **structured** keys (`TARGET_TYPES`, `REASONS`, `RULES`,
18
+ `SANCTION_LADDER`, `BLACKLIST_KINDS`, and the three `*_SCHEDULE` dicts)
19
+ carry shapes an environment string cannot express, so in practice they are
20
+ set in the settings dict and `AppSettings` never reads them from the
21
+ environment;
22
+ - `SCREENER` is the module's only `import_strings` key. It names a callable
23
+ the process imports and runs, so it is **env-closed by policy** — a stray
24
+ export in an entrypoint must not get to choose which classifier decides
25
+ what gets published.
26
+
27
+ ## stapel-moderation
28
+
29
+ ### Registries (source: env)
30
+
31
+ | Key | Source | Purpose | Required | Default |
32
+ |-----|--------|---------|----------|---------|
33
+ | TARGET_TYPES | env | The flagship merge-registry: `{target_type: policy dict or None}`. Built-ins are EMPTY — the module ships knowing no targets, so a host declares what may be moderated. `None` removes a type. Checks `E001`–`E006` police it; `E004` refuses a policy with no `content_function`. | no | `{}` |
34
+ | REASONS | env | Complaint taxonomy merged OVER a non-empty built-in set (spam, offensive, harassment, counterfeit, fraud, illegal, adult, personal_data, off_platform_payment, wrong_category, other). `None` removes. System reasons cannot be removed. | no | `{}` |
35
+ | RULES | env | Deterministic pre-LLM screening rules, `{code: {pattern, decision, severity, reason_code, applies_to}}`. Ships EMPTY: a shipped keyword list is somebody else's speech policy. | no | `{}` |
36
+
37
+ ### Target policy defaults (source: env)
38
+
39
+ | Key | Source | Purpose | Required | Default |
40
+ |-----|--------|---------|----------|---------|
41
+ | GATE_DEFAULT | env | `pre` (target waits for a verdict) or `post` (target is live and can be taken down), for types that do not say. | no | `post` |
42
+
43
+ ### Screening (source: env)
44
+
45
+ | Key | Source | Purpose | Required | Default |
46
+ |-----|--------|---------|----------|---------|
47
+ | SCREENER | env | Dotted path to `(case, content, *, reports) -> ScreeningResult`. The seam a host ML provider plugs into instead of forking. Env-closed by `import_strings` policy. | no | `stapel_moderation.screening.default_screener` |
48
+ | SCREEN_ENABLED | env | Master switch for the automatic stage. Off = every case goes straight to a person — the honest configuration with no model available. | no | `True` |
49
+ | SCREEN_MAX_ATTEMPTS | env | Retries of the `moderation.screen` comm-Task before it parks FAILED. | no | `3` |
50
+ | SCREEN_DEADLINE_SECONDS | env | Task deadline; `sweep_tasks` drops overdue ones. | no | `900` |
51
+ | SCREEN_TIMEOUT_SECONDS | env | Timeout of the `llm.complete` call. Deliberately not core's 5s `FUNCTION_TIMEOUT` — a completion never finishes in five seconds. | no | `60` |
52
+ | CONTENT_TIMEOUT_SECONDS | env | Timeout of a target's `content_function`. | no | `10` |
53
+ | LLM_MODEL | env | Size passed to `llm.complete`: `small`/`medium`/`large`. | no | `medium` |
54
+ | LLM_PROVIDER | env | Provider name; empty lets `llm.complete` pick its configured default. | no | `""` |
55
+ | LLM_CONFIDENCE_FLOOR | env | Below it the decision is forced to `needs_review` whatever the model said. | no | `0.7` |
56
+ | ON_SCREENING_FAILURE | env | `hold` \| `approve` \| `reject` when the screener could not answer. **Closed default `hold`**: the human queue IS the fallback. Anything else prints `moderation.W001`. | no | `hold` |
57
+ | ON_SCREENING_UNAVAILABLE | env | Same vocabulary, for "no screener configured at all". | no | `hold` |
58
+ | AUTO_RESOLVE_STALE_QUEUE | env | Seconds after which an unreviewed QUEUED case auto-approves. `None` = never, and never is the point. A number prints `moderation.W002`. | no | `None` |
59
+
60
+ ### Queue (source: env)
61
+
62
+ | Key | Source | Purpose | Required | Default |
63
+ |-----|--------|---------|----------|---------|
64
+ | CLAIM_LEASE_SECONDS | env | How long a moderator's claim holds before `sweep_stale_cases` returns the case to the queue. | no | `900` |
65
+ | MAX_PAGE_SIZE | env | Server ceiling on any keyset page. | no | `100` |
66
+ | APPLY_ACK_TIMEOUT_SECONDS | env | Seconds to wait for a target module's optional `moderation.applied` ack. `None` = do not wait; no released consumer emits one. | no | `None` |
67
+
68
+ ### Evidence and the wire (source: env)
69
+
70
+ | Key | Source | Purpose | Required | Default |
71
+ |-----|--------|---------|----------|---------|
72
+ | EVIDENCE_EXCERPT_CHARS | env | Length of the content excerpt STORED with a verdict (never emitted), so a statement of reasons stays checkable after the content is gone. | no | `512` |
73
+ | VERDICT_NOTE_WIRE_CHARS | env | Truncation of the statement of reasons that DOES ride `moderation.completed`. | no | `200` |
74
+
75
+ ### Media screening (source: env)
76
+
77
+ | Key | Source | Purpose | Required | Default |
78
+ |-----|--------|---------|----------|---------|
79
+ | MEDIA_SCREEN_TIER | env | Largest CDN variant width handed to the model. | no | `1024` |
80
+ | MAX_MEDIA_PER_CASE | env | Image prompts disable the provider's prompt cache, so every media screen is paid in full — this is the price control. | no | `4` |
81
+ | MEDIA_TRANSPORT | env | `url` (the model vendor fetches it — needs publicly reachable CDN URLs) or `data_b64` (inlined, bounded by the broker's max payload). | no | `url` |
82
+
83
+ ### Complaint intake (source: env)
84
+
85
+ | Key | Source | Purpose | Required | Default |
86
+ |-----|--------|---------|----------|---------|
87
+ | ALLOW_ANONYMOUS_REPORTS | env | **Closed.** Opening it makes `contact_email` mandatory and demands a captcha; without one, prints `moderation.W003`. | no | `False` |
88
+ | REPORT_THROTTLE | env | DRF scoped rate for the report endpoint. A library cannot own `DEFAULT_THROTTLE_RATES`, so the rate lives here. `None` disables — a conscious act. | no | `20/h` |
89
+
90
+ ### Appeals (source: env)
91
+
92
+ | Key | Source | Purpose | Required | Default |
93
+ |-----|--------|---------|----------|---------|
94
+ | APPEAL_REQUIRES_DIFFERENT_ACTOR | env | The moderator who decided may not decide the appeal. A one-moderator team turns it off knowingly. | no | `True` |
95
+ | APPEAL_URL_TEMPLATE | env | Template with `{case_id}` for the `appeal_url` notification variable. Empty yields `""` rather than an invented address. | no | `""` |
96
+
97
+ ### Sanctions (source: env)
98
+
99
+ | Key | Source | Purpose | Required | Default |
100
+ |-----|--------|---------|----------|---------|
101
+ | SANCTION_LADDER | env | `{kind: [seconds, ...]}`; the n-th sanction of a kind takes the n-th rung, the last rung repeats, `None` = permanent. | no | `{"posting_restricted": [86400, 604800, 2592000], "suspended": [604800, 2592000, None]}` |
102
+ | BLACKLIST_KINDS | env | Which sanction kinds kill live sessions through core's cross-service user blacklist. | no | `["suspended", "banned"]` |
103
+ | BLACKLIST_TTL_SECONDS | env | TTL of that cache key. The row stays the truth; `rearm_active_sanctions` keeps the key alive. | no | `7200` |
104
+
105
+ ### Scheduled work (source: env)
106
+
107
+ | Key | Source | Purpose | Required | Default |
108
+ |-----|--------|---------|----------|---------|
109
+ | SWEEP_SCHEDULE | env | crontab kwargs for `sweep_stale_cases`. | no | `{"minute": "*/5"}` |
110
+ | REARM_SCHEDULE | env | crontab kwargs for `rearm_active_sanctions` and `expire_sanctions`. Must be well inside `BLACKLIST_TTL_SECONDS`. | no | `{"minute": "*/30"}` |
111
+ | PURGE_SCHEDULE | env | crontab kwargs for `purge_expired_cases`. | no | `{"hour": 4, "minute": 20}` |
112
+ | RETENTION_DAYS | env | How long a resolved case is kept — one annual reporting cycle. | no | `365` |
113
+ | SANCTION_RETENTION_DAYS | env | Longer than the case, because the progressive ladder IS memory. | no | `1095` |
114
+ | EXPORT_PAGE_SIZE | env | Rows per page of the projection rebuild snapshot. | no | `500` |
115
+
116
+ ### Notifications (source: env)
117
+
118
+ | Key | Source | Purpose | Required | Default |
119
+ |-----|--------|---------|----------|---------|
120
+ | NOTIFY_COOLDOWN_SECONDS | env | One letter per (type, recipient) per window. Forty reports about one listing must not become forty letters to its author. | no | `600` |
121
+
122
+ ### Tenancy (source: env)
123
+
124
+ | Key | Source | Purpose | Required | Default |
125
+ |-----|--------|---------|----------|---------|
126
+ | WORKSPACE_SCOPED | env | Declared and closed. The capability names ship on day one so role overlays never migrate; the branch is not built, so turning this on denies loudly rather than granting silently. | no | `False` |
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-moderation
3
+ Version: 0.1.0
4
+ Summary: Target-generic moderation queue: reports, verdicts, sanctions and appeals for the Stapel framework
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/usestapel/stapel-moderation
7
+ Project-URL: Repository, https://github.com/usestapel/stapel-moderation
8
+ Project-URL: Documentation, https://github.com/usestapel/stapel-moderation#readme
9
+ Project-URL: Changelog, https://github.com/usestapel/stapel-moderation/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/usestapel/stapel-moderation/issues
11
+ Keywords: django,stapel,moderation,trust-and-safety,dsa
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: stapel-core<1.0,>=0.32.0
26
+ Provides-Extra: all
27
+ Dynamic: license-file
28
+
29
+ <!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
30
+
31
+ # stapel-moderation
32
+
33
+ [![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)
34
+ [![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)
35
+ [![pypi](https://img.shields.io/pypi/v/stapel-moderation?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-moderation/)
36
+ [![downloads](https://static.pepy.tech/badge/stapel-moderation/month)](https://pepy.tech/project/stapel-moderation)
37
+ [![python](https://img.shields.io/pypi/pyversions/stapel-moderation?logo=python&logoColor=white)](https://pypi.org/project/stapel-moderation/)
38
+ [![license](https://img.shields.io/github/license/usestapel/stapel-moderation)](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE)
39
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
40
+
41
+ > 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.
42
+
43
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install stapel-moderation
49
+ ```
50
+
51
+ ## At a glance
52
+
53
+ | Fact | Value |
54
+ |---|---|
55
+ | Version | `0.1.0` |
56
+ | Python | `>=3.11` (3.11, 3.12, 3.13) |
57
+ | HTTP operations | 18 |
58
+ | Config axes | 11 |
59
+ | Usage surface | 56 |
60
+ | Extension points | 6 |
61
+ | Error codes | 69 |
62
+ | 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) |
63
+
64
+ ## Documentation
65
+
66
+ [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)
67
+
68
+ ## What this is
69
+
70
+ One **moderation queue** for everything a product publishes. A listing, a
71
+ review, a chat message and an avatar are the same kind of work item here, and
72
+ one moderator console works all of them.
73
+
74
+ Three decisions carry the whole design.
75
+
76
+ **The unit of work is a Case, not a complaint.** Forty people reporting one
77
+ listing produce forty `Report` rows hanging off **one** `Case` with
78
+ `report_count = 40`. The system this replaced kept forty queue rows, which is
79
+ why its moderators saw the same listing forty times and why its queue page
80
+ read two whole tables into memory before it could show anybody anything.
81
+
82
+ **There is one status vocabulary in the module: `Case.state`.** A `Report` has
83
+ no status of its own and inherits its case's; a `Verdict` has none because it
84
+ is an append-only fact; a `Sanction` has an orthogonal lifecycle that never
85
+ mixes with a case state. The predecessor had three near-identical unrelated
86
+ status enums plus two more free copies in a serializer and an HTML template,
87
+ and they could and did disagree.
88
+
89
+ **Moderation never calls a host back to mutate it.** Resolving a case emits
90
+ `moderation.completed`, and the target module applies the verdict to itself —
91
+ [stapel-listings](https://github.com/usestapel/stapel-listings) 0.4.0 and
92
+ [stapel-reviews](https://github.com/usestapel/stapel-reviews) 0.2.0 already
93
+ consume it. The action IS the fact, so a new kind of moderated thing is a
94
+ registry entry plus a consumer in its own repository, never a branch in here.
95
+
96
+ ## Quick start
97
+
98
+ ```bash
99
+ pip install stapel-moderation
100
+ ```
101
+
102
+ ```python
103
+ INSTALLED_APPS = [
104
+ # ...
105
+ "stapel_moderation",
106
+ ]
107
+
108
+ # urls.py
109
+ path("moderation/", include("stapel_moderation.urls")) # -> /moderation/api/v1/...
110
+
111
+ # Declare what may be moderated. The module ships knowing NO target types.
112
+ STAPEL_MODERATION = {
113
+ "TARGET_TYPES": {
114
+ "listing": {
115
+ "intake_events": ["listing.submitted"],
116
+ "id_field": "listing_id",
117
+ "content_function": "listings.moderation_content",
118
+ "notification_types": {"content_blocked": "listing_blocked"},
119
+ },
120
+ "review": {
121
+ "id_field": "review_id",
122
+ "content_function": "reviews.moderation_content",
123
+ },
124
+ },
125
+ }
126
+
127
+ # Moderator rights are staff roles + the core mandate. No allow-list.
128
+ STAPEL_ACCESS = {"ROLES": {
129
+ "moderator": {"clearance": "low", "apps": {"moderation": "mid"}}, # read the queue
130
+ "ts_lead": {"clearance": "mid", "apps": {"moderation": "high"}}, # decide and sanction
131
+ }}
132
+
133
+ # The scheduled half. Without it, long suspensions stop being enforced.
134
+ from stapel_moderation.tasks import get_moderation_beat_schedule
135
+ CELERY_BEAT_SCHEDULE = {**get_moderation_beat_schedule()}
136
+ ```
137
+
138
+ ## How a case moves
139
+
140
+ ```
141
+ listing.submitted ──▶ open_case ──▶ comm-Task "moderation.screen"
142
+
143
+ ┌────────────────────┼────────────────────┐
144
+ ▼ ▼ ▼
145
+ rules hit llm.complete unavailable
146
+ (no LLM billed) (schema-constrained) (retry ×3, then
147
+ │ │ ON_SCREENING_FAILURE)
148
+ └────────┬───────────┘ │
149
+ ▼ ▼
150
+ approved / rejected needs_review ──▶ human queue
151
+ │ │
152
+ ▼ ▼
153
+ emit moderation.completed ◀────────────── moderator verdict
154
+ │ (+ optional sanction)
155
+
156
+ the target module blocks itself
157
+ ```
158
+
159
+ ## The switches that ship closed
160
+
161
+ Every setting that trades safety for availability is off by default, and the
162
+ three that matter print a startup warning when a host turns them on — because
163
+ each one is *invisible at runtime*, and the predecessor system had two of them
164
+ silently enabled for years.
165
+
166
+ | setting | default | what opening it costs |
167
+ |---|---|---|
168
+ | `ON_SCREENING_FAILURE` | `"hold"` | `"approve"` publishes content nobody screened; `"reject"` removes content nobody screened. Either prints `W001`. |
169
+ | `AUTO_RESOLVE_STALE_QUEUE` | `None` | A number makes unreviewed cases approve themselves on a clock. Prints `W002`. |
170
+ | `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`. |
171
+ | `APPEAL_REQUIRES_DIFFERENT_ACTOR` | `True` | Off, the moderator who decided also hears the appeal. |
172
+
173
+ ## What a ban actually does
174
+
175
+ `Sanction` is a row with a kind, a scope, a reason, a clock, an appeal and an
176
+ audit trail — not a boolean. Its teeth are `stapel-core`'s cross-service user
177
+ blacklist, which DRF authentication, the middleware (twice), channels and the
178
+ auth refresh endpoint all already check on every request, and which **had no
179
+ producer anywhere in the fleet** until this module. Deactivating the account
180
+ instead would touch no live session at all: `is_active` is only consulted when
181
+ a new token is issued.
182
+
183
+ Two operational consequences, stated rather than discovered:
184
+
185
+ - the blacklist is a cache key with a TTL, so `rearm_active_sanctions` must be
186
+ scheduled — otherwise a thirty-day suspension quietly stops being enforced
187
+ after two hours while the row still reads `active`. `W004` says so;
188
+ - core fails **closed** when that cache is unreachable, so a Redis outage
189
+ locks everybody out, not just the sanctioned. That is a property of core's
190
+ blacklist, and it belongs in the runbook.
191
+
192
+ ## Notice-and-action artefacts
193
+
194
+ The compliance surface is generated from the registries, not maintained as
195
+ prose beside them:
196
+
197
+ - `GET /moderation/api/v1/policy` — public, and assembled from the reason
198
+ registry, the rule registry and the actual screening settings, so it cannot
199
+ describe a system other than the one running;
200
+ - every takedown carries a statement of reasons and an appeal link;
201
+ - every complainant gets an acknowledgement and, later, the outcome;
202
+ - an appeal reopens and re-decides its case rather than filing a letter — the
203
+ one backward edge in the state machine exists for exactly that.
204
+
205
+ ## The Django admin is read-only, on purpose
206
+
207
+ Moderators *are* Django staff here, so the usual "different audience" argument
208
+ does not apply. The reason is path integrity: in the predecessor, admin bulk
209
+ actions flipped report statuses through `queryset.update()` — no audit row, no
210
+ timestamp, and the reviewed content was never actually hidden. A second
211
+ resolution path existed, invisible to the audit log. Read-only registration
212
+ makes that path impossible by construction, and `CaseEvent` is declared
213
+ `@access.ops`, whose mutations are `FORBIDDEN` even for a superuser.
214
+
215
+ ## License
216
+
217
+ MIT — see [LICENSE](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE).
218
+
219
+ ---
220
+
221
+ <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>
@@ -0,0 +1,193 @@
1
+ <!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
2
+
3
+ # stapel-moderation
4
+
5
+ [![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)
6
+ [![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)
7
+ [![pypi](https://img.shields.io/pypi/v/stapel-moderation?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-moderation/)
8
+ [![downloads](https://static.pepy.tech/badge/stapel-moderation/month)](https://pepy.tech/project/stapel-moderation)
9
+ [![python](https://img.shields.io/pypi/pyversions/stapel-moderation?logo=python&logoColor=white)](https://pypi.org/project/stapel-moderation/)
10
+ [![license](https://img.shields.io/github/license/usestapel/stapel-moderation)](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE)
11
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
12
+
13
+ > 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.
14
+
15
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install stapel-moderation
21
+ ```
22
+
23
+ ## At a glance
24
+
25
+ | Fact | Value |
26
+ |---|---|
27
+ | Version | `0.1.0` |
28
+ | Python | `>=3.11` (3.11, 3.12, 3.13) |
29
+ | HTTP operations | 18 |
30
+ | Config axes | 11 |
31
+ | Usage surface | 56 |
32
+ | Extension points | 6 |
33
+ | Error codes | 69 |
34
+ | 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) |
35
+
36
+ ## Documentation
37
+
38
+ [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)
39
+
40
+ ## What this is
41
+
42
+ One **moderation queue** for everything a product publishes. A listing, a
43
+ review, a chat message and an avatar are the same kind of work item here, and
44
+ one moderator console works all of them.
45
+
46
+ Three decisions carry the whole design.
47
+
48
+ **The unit of work is a Case, not a complaint.** Forty people reporting one
49
+ listing produce forty `Report` rows hanging off **one** `Case` with
50
+ `report_count = 40`. The system this replaced kept forty queue rows, which is
51
+ why its moderators saw the same listing forty times and why its queue page
52
+ read two whole tables into memory before it could show anybody anything.
53
+
54
+ **There is one status vocabulary in the module: `Case.state`.** A `Report` has
55
+ no status of its own and inherits its case's; a `Verdict` has none because it
56
+ is an append-only fact; a `Sanction` has an orthogonal lifecycle that never
57
+ mixes with a case state. The predecessor had three near-identical unrelated
58
+ status enums plus two more free copies in a serializer and an HTML template,
59
+ and they could and did disagree.
60
+
61
+ **Moderation never calls a host back to mutate it.** Resolving a case emits
62
+ `moderation.completed`, and the target module applies the verdict to itself —
63
+ [stapel-listings](https://github.com/usestapel/stapel-listings) 0.4.0 and
64
+ [stapel-reviews](https://github.com/usestapel/stapel-reviews) 0.2.0 already
65
+ consume it. The action IS the fact, so a new kind of moderated thing is a
66
+ registry entry plus a consumer in its own repository, never a branch in here.
67
+
68
+ ## Quick start
69
+
70
+ ```bash
71
+ pip install stapel-moderation
72
+ ```
73
+
74
+ ```python
75
+ INSTALLED_APPS = [
76
+ # ...
77
+ "stapel_moderation",
78
+ ]
79
+
80
+ # urls.py
81
+ path("moderation/", include("stapel_moderation.urls")) # -> /moderation/api/v1/...
82
+
83
+ # Declare what may be moderated. The module ships knowing NO target types.
84
+ STAPEL_MODERATION = {
85
+ "TARGET_TYPES": {
86
+ "listing": {
87
+ "intake_events": ["listing.submitted"],
88
+ "id_field": "listing_id",
89
+ "content_function": "listings.moderation_content",
90
+ "notification_types": {"content_blocked": "listing_blocked"},
91
+ },
92
+ "review": {
93
+ "id_field": "review_id",
94
+ "content_function": "reviews.moderation_content",
95
+ },
96
+ },
97
+ }
98
+
99
+ # Moderator rights are staff roles + the core mandate. No allow-list.
100
+ STAPEL_ACCESS = {"ROLES": {
101
+ "moderator": {"clearance": "low", "apps": {"moderation": "mid"}}, # read the queue
102
+ "ts_lead": {"clearance": "mid", "apps": {"moderation": "high"}}, # decide and sanction
103
+ }}
104
+
105
+ # The scheduled half. Without it, long suspensions stop being enforced.
106
+ from stapel_moderation.tasks import get_moderation_beat_schedule
107
+ CELERY_BEAT_SCHEDULE = {**get_moderation_beat_schedule()}
108
+ ```
109
+
110
+ ## How a case moves
111
+
112
+ ```
113
+ listing.submitted ──▶ open_case ──▶ comm-Task "moderation.screen"
114
+
115
+ ┌────────────────────┼────────────────────┐
116
+ ▼ ▼ ▼
117
+ rules hit llm.complete unavailable
118
+ (no LLM billed) (schema-constrained) (retry ×3, then
119
+ │ │ ON_SCREENING_FAILURE)
120
+ └────────┬───────────┘ │
121
+ ▼ ▼
122
+ approved / rejected needs_review ──▶ human queue
123
+ │ │
124
+ ▼ ▼
125
+ emit moderation.completed ◀────────────── moderator verdict
126
+ │ (+ optional sanction)
127
+
128
+ the target module blocks itself
129
+ ```
130
+
131
+ ## The switches that ship closed
132
+
133
+ Every setting that trades safety for availability is off by default, and the
134
+ three that matter print a startup warning when a host turns them on — because
135
+ each one is *invisible at runtime*, and the predecessor system had two of them
136
+ silently enabled for years.
137
+
138
+ | setting | default | what opening it costs |
139
+ |---|---|---|
140
+ | `ON_SCREENING_FAILURE` | `"hold"` | `"approve"` publishes content nobody screened; `"reject"` removes content nobody screened. Either prints `W001`. |
141
+ | `AUTO_RESOLVE_STALE_QUEUE` | `None` | A number makes unreviewed cases approve themselves on a clock. Prints `W002`. |
142
+ | `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`. |
143
+ | `APPEAL_REQUIRES_DIFFERENT_ACTOR` | `True` | Off, the moderator who decided also hears the appeal. |
144
+
145
+ ## What a ban actually does
146
+
147
+ `Sanction` is a row with a kind, a scope, a reason, a clock, an appeal and an
148
+ audit trail — not a boolean. Its teeth are `stapel-core`'s cross-service user
149
+ blacklist, which DRF authentication, the middleware (twice), channels and the
150
+ auth refresh endpoint all already check on every request, and which **had no
151
+ producer anywhere in the fleet** until this module. Deactivating the account
152
+ instead would touch no live session at all: `is_active` is only consulted when
153
+ a new token is issued.
154
+
155
+ Two operational consequences, stated rather than discovered:
156
+
157
+ - the blacklist is a cache key with a TTL, so `rearm_active_sanctions` must be
158
+ scheduled — otherwise a thirty-day suspension quietly stops being enforced
159
+ after two hours while the row still reads `active`. `W004` says so;
160
+ - core fails **closed** when that cache is unreachable, so a Redis outage
161
+ locks everybody out, not just the sanctioned. That is a property of core's
162
+ blacklist, and it belongs in the runbook.
163
+
164
+ ## Notice-and-action artefacts
165
+
166
+ The compliance surface is generated from the registries, not maintained as
167
+ prose beside them:
168
+
169
+ - `GET /moderation/api/v1/policy` — public, and assembled from the reason
170
+ registry, the rule registry and the actual screening settings, so it cannot
171
+ describe a system other than the one running;
172
+ - every takedown carries a statement of reasons and an appeal link;
173
+ - every complainant gets an acknowledgement and, later, the outcome;
174
+ - an appeal reopens and re-decides its case rather than filing a letter — the
175
+ one backward edge in the state machine exists for exactly that.
176
+
177
+ ## The Django admin is read-only, on purpose
178
+
179
+ Moderators *are* Django staff here, so the usual "different audience" argument
180
+ does not apply. The reason is path integrity: in the predecessor, admin bulk
181
+ actions flipped report statuses through `queryset.update()` — no audit row, no
182
+ timestamp, and the reviewed content was never actually hidden. A second
183
+ resolution path existed, invisible to the audit log. Read-only registration
184
+ makes that path impossible by construction, and `CaseEvent` is declared
185
+ `@access.ops`, whose mutations are `FORBIDDEN` even for a superuser.
186
+
187
+ ## License
188
+
189
+ MIT — see [LICENSE](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE).
190
+
191
+ ---
192
+
193
+ <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>
@@ -0,0 +1,48 @@
1
+ """stapel-moderation — the fleet's single producer of moderation verdicts.
2
+
3
+ A target-generic queue over every kind of moderated thing (listings, reviews,
4
+ chat messages, profiles): one ``Case`` per target, one status vocabulary, an
5
+ append-only audit trail, LLM-first screening with a human always in the loop,
6
+ and sanctions with a reason, a scope, a clock and an appeal.
7
+
8
+ Public API (lazily exported, PEP 562 — importing this package never pulls in
9
+ Django or requires configured settings):
10
+
11
+ - ``moderation_settings`` — resolved app settings (``stapel_moderation.conf``);
12
+ - ``register_target_type`` / ``register_reason`` / ``register_rule`` — the
13
+ three merge-registries (``stapel_moderation.registry``);
14
+ - ``NotSanctioned`` — the DRF permission a HOST hangs on its own write views
15
+ to refuse a sanctioned user (``stapel_moderation.authz``).
16
+ """
17
+
18
+ __all__ = [
19
+ "moderation_settings",
20
+ "register_reason",
21
+ "register_rule",
22
+ "register_target_type",
23
+ "NotSanctioned",
24
+ ]
25
+
26
+ # name -> submodule that defines it. Resolution is deferred until first
27
+ # attribute access so that `import stapel_moderation` stays Django-free.
28
+ _LAZY_EXPORTS = {
29
+ "moderation_settings": ".conf",
30
+ "register_target_type": ".registry",
31
+ "register_reason": ".registry",
32
+ "register_rule": ".registry",
33
+ "NotSanctioned": ".authz",
34
+ }
35
+
36
+
37
+ def __getattr__(name):
38
+ if name in _LAZY_EXPORTS:
39
+ from importlib import import_module
40
+
41
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
42
+ globals()[name] = value # cache for subsequent lookups
43
+ return value
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
45
+
46
+
47
+ def __dir__():
48
+ return sorted(set(globals()) | set(__all__))