|
# DevPlace: App Store compliance implementation design
|
|
|
|
Author: retoor <retoor@molodetz.nl>
|
|
|
|
Stage three of `apple.md`. Inputs are [`applecomp.md`](applecomp.md) (what Apple requires) and [`applechanges.md`](applechanges.md) (what DevPlace lacks). This document is the design: the most consistent, DRY, caveat-free way to implement every gap inside the conventions this codebase already enforces.
|
|
|
|
Nothing here is implemented. This is the specification the lord is asked to approve.
|
|
|
|
---
|
|
|
|
## 1. Design axioms
|
|
|
|
Each axiom is derived from an existing DevPlace pattern, named with its precedent. No axiom is invented for this feature.
|
|
|
|
| # | Axiom | Precedent in the codebase |
|
|
|---|-------|---------------------------|
|
|
| **A1** | **One polymorphic facility, never twenty per-surface features.** A report is structurally a vote: an actor, a `(target_type, target_uid)` pair, a payload. | `comments`, `votes`, `reactions`, `bookmarks` all key on `(target_type, target_uid)`; `VOTABLE_TARGETS` in `database/ranking.py:11`; `REACTABLE` in `routers/reactions.py:17` |
|
|
| **A2** | **The target set is a registry, not a literal.** Every consumer reads the same dict; adding a surface is one line. | `VOTABLE_TARGETS`, `STAR_TARGETS`, `NOTIFICATION_TYPES`, `SOFT_DELETE_TABLES`, `DOCS_PAGES`, `DATA_PATHS` |
|
|
| **A3** | **Machine-raised and human-raised entries share one queue and one state machine.** | `services/containers/workspace/flags.py`: `raise_flag` is machine-driven, `set_status` is admin-driven, statuses `open/acknowledged/resolved/dismissed`, severities `info/warn/critical` |
|
|
| **A4** | **Every route has four faces:** HTML, JSON, Devii action, API docs. | Root `CLAUDE.md`, "Anatomy of a feature" |
|
|
| **A5** | **Removal is soft; garbage collection is hard; cascades share one stamp.** | `database/soft_delete.py`, `SOFT_DELETE_TABLES` (44 tables), `soft_delete_in`, `purge_event`, `/admin/trash` |
|
|
| **A6** | **Runtime policy lives in `site_settings`,** read through `get_setting`/`get_int_setting`, live-editable at `/admin/settings`, never in code constants. | `database/schema.py:276`, `rate_limit_per_minute`, `maintenance_mode`, `registration_open` |
|
|
| **A7** | **Action bars are composed from included partials** with `{% set _type %}{% set _uid %}{% include %}`. | `_reaction_bar.html` included from `_post_card.html:37` and `_comment.html:37` |
|
|
| **A8** | **Non-response-critical side-effects go through `background.submit`;** audit and notifications are already funnelled there. | `services/background.py`, `utils/notifications.py:68` |
|
|
| **A9** | **Never a silent failure.** A safety control that swallows an error is worse than absent. | Root `CLAUDE.md`; `services/audit` never raises into the caller but always records |
|
|
| **A10** | **Legal and policy prose is a docs page,** with the existing role gating, SEO context and search index. | `routers/docs/pages.py` `DOCS_PAGES`; the admin-only `media-moderation` page proves gating works |
|
|
| **A11** | **Owner-or-admin, with the seniority guard on admin-versus-admin.** | `content.is_owner`, `_is_senior_admin` in `routers/admin/users.py` |
|
|
| **A12** | **The AI gateway is the single choke point for third-party model calls,** so consent is enforced in exactly one place. | `services/openai_gateway/`, `INTERNAL_GATEWAY_URL` |
|
|
|
|
---
|
|
|
|
## 2. The unifying abstraction
|
|
|
|
Everything in this design hangs off **one registry** and **one queue**.
|
|
|
|
### 2.1 The moderation target registry
|
|
|
|
New module `devplacepy/database/moderation.py`, mirroring `database/ranking.py` exactly in shape and placement:
|
|
|
|
```
|
|
REPORTABLE_TARGETS: dict[str, str] # target_type -> table name
|
|
MATURITY_TARGETS: set[str] # subset that can carry an age label
|
|
```
|
|
|
|
`REPORTABLE_TARGETS` covers every externally-visible surface from `applechanges.md` §2:
|
|
|
|
`post`, `comment`, `gist`, `project`, `project_file`, `news`, `attachment`, `message`, `quiz`, `poll`, `award`, `user`, `issue`, `workspace`, `devii_output`.
|
|
|
|
`MATURITY_TARGETS` is the subset that renders long-form authored content: `post`, `comment`, `gist`, `project`, `news`, `attachment`, `quiz`.
|
|
|
|
**Why a registry rather than per-surface code.** A report route, a report button, a moderation queue row, a Devii action parameter enum, an API docs enum and a test fixture all need the same list. With a registry they read it; without one they drift. This is the same reason `VOTABLE_TARGETS` exists.
|
|
|
|
**The completeness invariant.** A unit test asserts that every entry in `REPORTABLE_TARGETS` resolves to a real table (or an explicitly listed virtual surface) **and** that every externally-visible table in `SOFT_DELETE_TABLES` appears in `REPORTABLE_TARGETS`. Adding a new UGC surface without adding it to the registry fails the suite. Requirement R5 is therefore satisfied not by diligence but by construction. This is the load-bearing correctness claim of the whole design; §11 formalises it.
|
|
|
|
### 2.2 The single queue
|
|
|
|
One table, `content_reports`, with two producers:
|
|
|
|
- **members**, via the report control on every content action bar;
|
|
- **the filter**, via a system-raised entry when classification returns `review`.
|
|
|
|
This is `workspace_flags` generalised from one instance type to the registry. Same state machine (`open → acknowledged → actioned | dismissed`), same severity ladder (`info | warn | critical`), same soft-delete participation, same admin resolution surface. One queue means one SLA measurement, one admin screen, one audit shape, and one place where the 24-hour commitment is either met or visibly not.
|
|
|
|
### 2.3 URL resolution is already solved
|
|
|
|
`database/content.py:22` `resolve_object_url(target_type, target_uid)` already maps `post`, `project`, `news`, `issue`, `gist`, `quiz`, `comment` and `award` to their canonical URLs, recursing through comments to their parents. It gains the remaining registry entries (`project_file`, `attachment`, `message`, `user`, `workspace`, `poll`, `devii_output`). Every moderation surface then links to its subject for free, using the function the notification system already uses.
|
|
|
|
---
|
|
|
|
## 3. Data layer
|
|
|
|
All schema changes land in `devplacepy/database/schema.py` `init_db()` following the existing `has_column` / `create_column_by_example` / `_index` idiom, and every new table is registered in `SOFT_DELETE_TABLES`.
|
|
|
|
### 3.1 `content_reports`
|
|
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| `uid` | text | `generate_uid()` |
|
|
| `reporter_uid` | text | user uid, or `system` for filter-raised (mirrors `audit.record_system`) |
|
|
| `target_type` | text | key of `REPORTABLE_TARGETS` |
|
|
| `target_uid` | text | subject uid |
|
|
| `owner_uid` | text | author of the reported content, denormalised at insert so the queue never N+1s |
|
|
| `reason` | text | key of `REPORT_REASONS` (§3.6) |
|
|
| `detail` | text | reporter's free text, max 2000 |
|
|
| `severity` | text | `info` / `warn` / `critical` |
|
|
| `status` | text | `open` / `acknowledged` / `actioned` / `dismissed` |
|
|
| `origin` | text | `member` / `filter` |
|
|
| `categories` | text | JSON list of matched 1.1.x category keys, filter-raised only |
|
|
| `resolved_by` | text | admin uid |
|
|
| `resolved_at` | text | ISO |
|
|
| `created_at`, `updated_at` | text | ISO |
|
|
| `deleted_at`, `deleted_by` | text | soft delete |
|
|
|
|
Indexes: `(status, created_at)` for the queue and the SLA scan; `(target_type, target_uid)` for "is this already reported"; `(reporter_uid)` for the reporter's own list; `(owner_uid)` for offender history. Partial soft-delete index per the standing convention.
|
|
|
|
**Duplicate handling** follows `raise_flag` precisely: an open report for the same `(target_type, target_uid, reporter_uid)` is updated, not duplicated. A different reporter on the same target creates a new row; the queue groups by target and shows the count, which is exactly how a real moderation queue prioritises.
|
|
|
|
### 3.2 `moderation_actions`
|
|
|
|
The decision record. One row per moderator decision, linked to the report that triggered it.
|
|
|
|
`uid`, `report_uid`, `actor_uid`, `action`, `target_type`, `target_uid`, `subject_uid`, `reason`, `notes`, `expires_at`, `created_at`, soft-delete columns.
|
|
|
|
`action` ∈ `remove_content`, `restore_content`, `warn`, `suspend`, `ban`, `lift`, `dismiss`, `escalate`.
|
|
|
|
This is the DSA statement-of-reasons substrate (P3) and the compliance-plan evidence (P1). It is separate from the audit log because the audit log is append-only infrastructure and this is queryable moderation state with its own lifecycle - the same reason `workspace_flags` exists alongside the audit log.
|
|
|
|
### 3.3 `content_maturity`
|
|
|
|
Polymorphic age label, one row per labelled item. `uid`, `target_type`, `target_uid`, `level`, `source`, `set_by`, `created_at`, soft-delete columns.
|
|
|
|
`level` ∈ `general`, `mature`, `restricted`. `source` ∈ `author`, `filter`, `moderator`.
|
|
|
|
Read through a batch helper `get_maturity_by_targets(target_type, uids)` modelled exactly on `database/engagement.py` `get_reactions_by_targets` - no N+1, one query per listing. Absence of a row means `general`, so nothing needs backfilling and no existing row is touched.
|
|
|
|
### 3.4 `user_consents`
|
|
|
|
`uid`, `owner_kind`, `owner_id`, `kind`, `version`, `state`, `granted_at`, `withdrawn_at`, `created_at`, soft-delete columns.
|
|
|
|
`owner_kind`/`owner_id` reuse the `owner_for(request)` convention from the customization subsystem verbatim, so guests are covered by the same table. `kind` ∈ `terms`, `privacy`, `ai_third_party`, `activity_recording`. `state` ∈ `granted`, `withdrawn`.
|
|
|
|
Consent is **versioned and append-only in effect**: withdrawing writes `withdrawn_at` and a new grant writes a new row, so the full consent history is provable - which is what GDPR and Apple both actually require.
|
|
|
|
### 3.5 `users` columns
|
|
|
|
Added with the existing `has_column` guard block at `database/schema.py:1823`:
|
|
|
|
| Column | Default | Purpose |
|
|
|--------|---------|---------|
|
|
| `terms_version` | `""` | Accepted document version (R2) |
|
|
| `terms_accepted_at` | `""` | ISO timestamp (R2) |
|
|
| `age_band` | `""` | `under_min` / `13_15` / `16_17` / `adult` (R13) |
|
|
| `age_declared_at` | `""` | ISO timestamp |
|
|
| `mature_opt_in` | `0` | Explicit opt-in to see mature-labelled content (R14) |
|
|
| `suspended_until` | `""` | ISO; empty means not suspended (R8) |
|
|
| `suspension_reason` | `""` | Shown to the user (P3) |
|
|
| `deletion_requested_at` | `""` | Starts the deletion clock (R12) |
|
|
|
|
**No birthdate is stored.** 5.1.4 permits collecting it only to comply with children's privacy statutes; data minimization (5.1.1(iii)) then requires storing only the derived band. The signup form collects a date, derives the band, and discards the date. This is both the compliant and the simpler design.
|
|
|
|
### 3.6 Registries and constants
|
|
|
|
`devplacepy/database/moderation.py` also owns:
|
|
|
|
- `REPORT_REASONS: dict[str, str]` - key to label, mapped one-to-one onto the guideline categories so the age-rating questionnaire and the community guidelines can be written from the same list: `hate` (1.1.1), `violence` (1.1.2), `weapons` (1.1.3), `sexual` (1.1.4), `religious` (1.1.5), `misinformation` (1.1.6), `exploitative` (1.1.7), `harassment`, `spam`, `intellectual_property` (5.2 / R18), `self_harm`, `illegal`, `other`.
|
|
- `REPORT_STATUSES`, `REPORT_SEVERITIES`, `MODERATION_ACTIONS`, `MATURITY_LEVELS`, `CONSENT_KINDS`, `AGE_BANDS`.
|
|
|
|
One list, consumed by the form validator, the Devii action schema, the API docs enum, the admin filter dropdown and the community-guidelines page. Changing a reason is one edit.
|
|
|
|
### 3.7 `site_settings` keys
|
|
|
|
Added to the defaults block at `database/schema.py:276`, editable live at `/admin/settings` (A6):
|
|
|
|
| Key | Default | Purpose |
|
|
|-----|---------|---------|
|
|
| `moderation_sla_hours` | `24` | The published commitment (R7) |
|
|
| `moderation_filter_mode` | `review` | `off` / `label` / `review` / `block` (R4) |
|
|
| `moderation_minimum_age` | `16` | Signup floor (R13) |
|
|
| `moderation_mature_default_hidden` | `1` | Mature content hidden by default (R14) |
|
|
| `contact_email`, `contact_phone`, `contact_address` | empty | Published contact + DSA trader data (R10, R22) |
|
|
| `terms_version`, `privacy_version`, `guidelines_version` | `1` | Bump forces re-acceptance (R2) |
|
|
| `ai_third_party_provider` | `""` | Named in the consent copy (R15) |
|
|
| `account_deletion_grace_hours` | `24` | Reversible window before purge (R12) |
|
|
|
|
---
|
|
|
|
## 4. The content filter
|
|
|
|
`devplacepy/services/moderation/` - a new service package alongside `services/audit/`, `services/game/` and the rest, with its own nested `CLAUDE.md`.
|
|
|
|
### 4.1 Shape
|
|
|
|
```
|
|
services/moderation/
|
|
__init__.py record()-style entrypoints, the only public surface
|
|
filter.py classify(text) -> Classification
|
|
rules.py the category rule set
|
|
queue.py raise_report / set_status / decide / list_reports
|
|
enforcement.py suspend / ban / lift / remove_content
|
|
sla.py oldest_open_age / breach_count
|
|
```
|
|
|
|
`Classification` is a frozen dataclass (`verdict`, `categories`, `maturity`, `score`) - dataclasses over fixed-key dicts, per the standing style rule.
|
|
|
|
`verdict` ∈ `allow`, `label`, `review`, `block`, resolved against `moderation_filter_mode` so an administrator can dial the platform from advisory to strict without a deploy.
|
|
|
|
### 4.2 Where it runs - exactly five call sites
|
|
|
|
The filter is invoked only at choke points that already exist, so no surface can be missed and no surface needs bespoke code:
|
|
|
|
1. `content.create_content_item` (`content.py:197`) - posts, projects, gists, news, quizzes.
|
|
2. `content.create_comment_record` (`content.py:361`) - every comment on every parent type.
|
|
3. `content.edit_content_item` and `content.edit_comment_record` - edits, so a clean post cannot be edited into a violation.
|
|
4. `routers/messages.py:245` `send_message` and the WebSocket send path - direct messages.
|
|
5. `routers/profile/index.py` profile update and `routers/auth/signup.py` - bio, location, links, username.
|
|
|
|
Five call sites cover twenty surfaces because the codebase already funnels creation. This is the direct payoff of DevPlace's existing structure.
|
|
|
|
### 4.3 Behaviour, and why it is safe on a developer platform
|
|
|
|
The single largest implementation risk identified in `applechanges.md` §7 is false positives: a security-focused developer community discusses exploits, weapons-grade cryptography and violent language in code review. A naive block destroys the product.
|
|
|
|
The design answers this structurally:
|
|
|
|
- **The default mode is `review`, not `block`.** A flagged item is published **and** a system report is raised. Nothing legitimate is ever suppressed by a machine.
|
|
- **Only the `sexual` and `exploitative` categories default to `block`**, because those are the two where Apple removes apps without notice and where no developer-platform false-positive case exists.
|
|
- **Thresholds are `site_settings`,** tunable live while watching the queue.
|
|
- **A failure in the filter fails to `review`, never to `allow`** (A9). If classification raises, the content is published and a `critical` system report is raised naming the failure. A moderation control that fails open is worse than absent.
|
|
|
|
This gives Apple the "method for filtering objectionable material from being posted" that 1.2 requires, gives the platform a human in the loop, and gives the community no false suppression.
|
|
|
|
---
|
|
|
|
## 5. Server layer
|
|
|
|
### 5.1 Reporting - `devplacepy/routers/reports.py`, mounted at `/reports`
|
|
|
|
Mirrors `routers/reactions.py` line for line.
|
|
|
|
| Method | Path | Auth | Purpose |
|
|
|--------|------|------|---------|
|
|
| `POST` | `/reports/{target_type}/{target_uid}` | member | Submit a report |
|
|
| `GET` | `/reports/mine` | member | The reporter's own reports and their outcomes (DSA Art. 16 acknowledgement) |
|
|
| `GET` | `/reports/reasons` | public | The reason registry, so any client renders the same dialog |
|
|
|
|
Input model `ReportForm` in `models.py` (`reason`, `detail`); output schema `ReportOut` / `ReportListOut` in `schemas/moderation.py`. `respond(request, template, ctx, model=ReportOut)` gives HTML and JSON from one handler. Rate limiting is already global on POST via the existing middleware; no per-route limiter is added.
|
|
|
|
Submitting a report **always** notifies the reporter through `create_notification` with the acknowledgement and the SLA, and **never** notifies the reported user (that happens only on decision, as a statement of reasons).
|
|
|
|
### 5.2 Moderation queue - `devplacepy/routers/admin/moderation.py`
|
|
|
|
Registered in the `admin/` package exactly like `trash.py` and `media.py`, with `admin_section = "moderation"`.
|
|
|
|
| Method | Path | Purpose |
|
|
|--------|------|---------|
|
|
| `GET` | `/admin/moderation` | The queue, grouped by target, sorted oldest-open-first, with the SLA badge |
|
|
| `GET` | `/admin/moderation/{uid}` | One report, its target rendered in place, the offender's history |
|
|
| `POST` | `/admin/moderation/{uid}/status` | `acknowledge` / `dismiss` |
|
|
| `POST` | `/admin/moderation/{uid}/decide` | Apply a `MODERATION_ACTIONS` decision |
|
|
|
|
Every decision writes a `moderation_actions` row, records an audit event, and - where the decision affects a user - delivers a statement of reasons through `create_notification`.
|
|
|
|
### 5.3 Enforcement - extending `routers/admin/users.py`
|
|
|
|
The bare `is_active` toggle at `admin/users.py:179` is kept for backward compatibility and joined by:
|
|
|
|
| Method | Path | Purpose |
|
|
|--------|------|---------|
|
|
| `POST` | `/admin/users/{uid}/suspend` | Reason + duration; writes `suspended_until`, `suspension_reason` |
|
|
| `POST` | `/admin/users/{uid}/lift` | Clears both |
|
|
| `POST` | `/admin/users/{uid}/ban` | Permanent; `is_active = False` **with** a recorded reason |
|
|
|
|
All three pass through the existing `_is_senior_admin(actor, target)` guard (A11), so a junior admin cannot suspend a senior one - server-side, therefore also covering Devii.
|
|
|
|
Enforcement is read by one new predicate in `content.py`, `is_suspended(user)`, consulted by `require_user` so a suspended account can still read, still see why, and still delete their account, but cannot post. This is one predicate at one choke point, not a scattered check.
|
|
|
|
### 5.4 Account deletion - `routers/profile/delete.py`
|
|
|
|
Follows the `regenerate-avatar` precedent (owner-or-admin, POST under `/profile/{username}/…`, audited, cache-invalidating).
|
|
|
|
| Method | Path | Purpose |
|
|
|--------|------|---------|
|
|
| `GET` | `/profile/{username}/delete` | The confirmation page: what will be deleted, what is retained and why, the grace window |
|
|
| `POST` | `/profile/{username}/delete` | Requires the account password (reauthentication, explicitly permitted by Apple); starts deletion |
|
|
|
|
**The cascade**, using one shared stamp (A5):
|
|
|
|
1. Stamp `deletion_requested_at`, revoke every session and access token, invalidate the user cache.
|
|
2. `soft_delete_in(table, "user_uid", [uid], deleted_by=uid, stamp=stamp)` across every table in `SOFT_DELETE_TABLES` that carries a `user_uid` - one stamp, so `/admin/trash` can restore the entire event atomically within the grace window.
|
|
3. Anonymise the `users` row immediately: username tombstoned, email, bio, location, links, avatar seed, API key and password hash cleared. **From the user's and every other user's point of view, the account is gone the moment they confirm.**
|
|
4. A GC sweep (`devplace accounts prune`, and a scheduled pass in the existing service manager) hard-purges the stamped event after `account_deletion_grace_hours`, using `purge_event(stamp)` - the function that already exists.
|
|
|
|
The confirmation page states the grace window explicitly, satisfying Apple's "if the deletion request will take additional time to complete, let them know."
|
|
|
|
The devRant `DELETE /api/users/me` at `routers/devrant/auth.py:189` is re-pointed at this same cascade, because a deactivation masquerading as a deletion is exactly what Apple names as insufficient, and because two paths must not mean two behaviours.
|
|
|
|
### 5.5 Terms, age and consent
|
|
|
|
| Method | Path | Purpose |
|
|
|--------|------|---------|
|
|
| `POST` | `/auth/accept-terms` | Records acceptance of the current `terms_version` |
|
|
| `POST` | `/profile/{username}/consent` | Grant or withdraw a `CONSENT_KINDS` entry |
|
|
| `GET` | `/profile/{username}?tab=privacy` | Acceptances, consents, withdrawal controls, deletion entry point |
|
|
|
|
`SignupForm` (`models.py:51`) gains `birth_date` and `accept_terms`, both required, validated Pydantic-natively like every other form in the project. The validator derives `age_band`, rejects below `moderation_minimum_age`, and the raw date never reaches the database.
|
|
|
|
**The re-acceptance gate** is a middleware in the existing stack in `main.py`, sitting beside the maintenance gate it is modelled on: an authenticated user whose `terms_version` is behind the setting is redirected to the acceptance page for any mutating request, while reads, `/static`, `/auth`, `/docs` and account deletion stay open. A user must never be trapped: they can always read, always accept, and always delete their account.
|
|
|
|
### 5.6 Third-party AI consent - one gate at one choke point
|
|
|
|
Enforced in `services/openai_gateway/` where every internal AI consumer already converges (A12).
|
|
|
|
The rule distinguishes two things that the existing code currently conflates:
|
|
|
|
- **User-content processing** - the user's own post, comment, message, file or prompt is sent to the provider. Requires a granted `ai_third_party` consent for that user. Default: **not granted**.
|
|
- **Platform processing** - news import, bot personas, SEO metadata for platform-owned text. Not user content, not gated by user consent.
|
|
|
|
The gateway resolves the owner it is acting for and refuses a user-content call without consent, returning a structured error the callers already know how to surface. The existing `ai_correction_enabled` and `ai_modifier_enabled` flags survive unchanged as **preferences**, subordinate to consent: consent withdrawn means the feature is off regardless of the preference. `ai_modifier_enabled`'s default of `1` becomes harmless, because consent gates it. No existing preference is silently flipped; the gate is simply added above them.
|
|
|
|
The consent copy names the provider from `ai_third_party_provider`, states what is sent and why, and links the privacy policy - the three things 5.1.2(i) demands.
|
|
|
|
### 5.7 Activity-recording consent and indicator (C8)
|
|
|
|
`activity_recording` consent covers presence (`services/presence.py`), the live view relay and Devii terminal session capture. Guideline 2.5.14 wants consent **and** a clear indication. The indication reuses the existing presence dot partial `_presence_dot.html` and the response-time badge idiom in `base.html`: a small, always-visible recording indicator when a session is being captured. Withdrawing consent stops presence writes for that user; they simply appear offline.
|
|
|
|
### 5.8 The software index (C5 / 4.7.4)
|
|
|
|
`GET /workspaces/index` - a public, paginated index of every user-published workspace reachable through the `/p/{slug}` ingress, with its owner, description, maturity label and canonical URL. This is the "index of software and metadata available in your app… including universal links" that 4.7.4 requires. It reuses the existing listing machinery (`build_pagination`, `_card_link.html`, `paginate_diverse`) and is added to the sitemap.
|
|
|
|
---
|
|
|
|
## 6. View layer
|
|
|
|
### 6.1 One partial, included everywhere
|
|
|
|
`templates/_report_button.html`, included with the same two-variable idiom as `_reaction_bar.html` (A7):
|
|
|
|
```
|
|
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _owner = item.post['user_uid'] %}
|
|
{% include "_report_button.html" %}
|
|
```
|
|
|
|
It renders **Report** and, when the viewer is not the owner, **Block**, both `guest_disabled(user)`, both matching the existing `post-action-btn` / `comment-action-btn` visual language exactly. Placing Block here closes gap G9 from `applechanges.md`: blocking becomes reachable from the content, not only from a profile.
|
|
|
|
Include sites: `_post_card.html`, `_comment.html`, `post.html`, `gist_detail.html`, `project_detail.html`, `news_detail.html`, `quiz.html`, `_media_gallery.html`, `messages.html`, `profile.html`, `_award_badge.html`, `project_files.html`, `issue_detail.html`, `containers_instance.html`.
|
|
|
|
**One partial, fourteen include sites, zero duplicated markup.** A template that renders content and omits the include is caught by the e2e coverage test in §10.
|
|
|
|
### 6.2 One dialog
|
|
|
|
`templates/_report_dialog.html` is included once in `base.html`, exactly as the reaction picker is a single palette reused by every bar. `static/js/ReportDialog.js` - one ES6 class, registered on `app`, using the existing `Http` helper and the established `.modal-overlay` / `.visible` modal pattern - reads `data-report-type` and `data-report-uid` from the clicked button, populates the reason list from `/reports/reasons`, and posts. No new modal machinery, no third-party library.
|
|
|
|
### 6.3 Maturity gate
|
|
|
|
`templates/_maturity_gate.html`: an interstitial rendered in place of a `mature`-labelled item for a viewer who has not opted in or whose `age_band` is below the threshold. Reveal is a single control that sets `mature_opt_in`; it is not offered at all to `13_15` or `16_17` bands for `restricted` content. Content stays hidden by default, which is precisely 1.2's wording.
|
|
|
|
### 6.4 Admin
|
|
|
|
`templates/admin_moderation.html` extends `admin_base.html` with `admin_section = "moderation"`, and a sidebar entry is added to `admin_base.html` between Media and Trash - the natural neighbours. The queue header carries the SLA badge: oldest open report age against `moderation_sla_hours`, green under, red over. That badge is the mechanism that makes the published 24-hour commitment (R7) real rather than aspirational.
|
|
|
|
### 6.5 Legal pages and the footer
|
|
|
|
Legal prose ships as `DOCS_PAGES` entries (A10) under a new `SECTION_LEGAL = "Legal"`, placed in the `AUDIENCE_START` group so it is one click from `/docs`:
|
|
|
|
| Slug | Title | Requirement |
|
|
|------|-------|-------------|
|
|
| `terms` | Terms of Service | R1, R2 |
|
|
| `community-guidelines` | Community Guidelines | R3 |
|
|
| `privacy` | Privacy Policy | R11 |
|
|
| `contact` | Contact | R10, R22 |
|
|
| `content-moderation` | How moderation works | R7, P1 |
|
|
| `intellectual-property` | Notice and takedown | R18 |
|
|
| `moderation-operations` | Operating the queue (admin-gated, like `media-moderation`) | P1, P2 |
|
|
|
|
`_footer_links.html` gains Terms, Privacy, Guidelines and Contact alongside the existing four links. This is the "easily accessible in the app" that 5.1.1(i) and 1.5 both require, and it is on every page because the footer is in `base.html`.
|
|
|
|
`contact` renders `contact_email`, `contact_phone` and `contact_address` from `site_settings`, so the in-app contact data and the App Store Connect trader data have one source of truth and cannot drift (R10 ≡ R22).
|
|
|
|
### 6.6 Signup
|
|
|
|
`templates/signup.html` gains a date-of-birth field and a required terms checkbox whose label links `/docs/terms.html` and `/docs/community-guidelines.html`. Both are validated by `SignupForm`, so the error path is the existing global `RequestValidationError` handler that already re-renders auth pages with messages.
|
|
|
|
---
|
|
|
|
## 7. Agent, docs and SEO layer
|
|
|
|
Per A4, nothing ships with fewer than four faces.
|
|
|
|
- **Devii** - `services/devii/actions/catalog/moderation.py` exporting `MODERATION_ACTIONS`: `report_content`, `list_my_reports`, `list_reports` (admin), `decide_report` (admin), `suspend_user` (admin), `lift_suspension` (admin), `delete_my_account`, `set_consent`, `accept_terms`. `delete_my_account`, `decide_report`, `suspend_user` and `ban_user` join `CONFIRM_REQUIRED` in `dispatcher.py`, **each declaring a `confirm` boolean param in its catalog spec** - the load-bearing detail the root `CLAUDE.md` calls out, without which a gated tool loops forever.
|
|
- **API docs** - `docs_api/groups/moderation.py`, a new group with `endpoint()` entries and `sample_response` for every route above, plus the reason enum sourced from `REPORT_REASONS`.
|
|
- **SEO** - legal pages are public and indexable, added to `routers/seo.py`'s sitemap; `/reports/*` and `/admin/moderation/*` are `noindex,nofollow` via `base_seo_context`.
|
|
- **Audit** - new keys in `events.md` and `services/audit/categories.py` `category_for` under a new `moderation` category: `report.create`, `report.status`, `report.decide`, `moderation.suspend`, `moderation.ban`, `moderation.lift`, `moderation.remove`, `moderation.restore`, `filter.block`, `filter.review`, `account.delete.request`, `account.delete.purge`, `consent.grant`, `consent.withdraw`, `terms.accept`.
|
|
- **README.md** gains the moderation, legal and account-deletion surfaces; the root `CLAUDE.md` gains one new architectural rule (§8.1 below); `services/moderation/CLAUDE.md` and `routers/CLAUDE.md` carry the detail.
|
|
|
|
---
|
|
|
|
## 8. The two things that are not code
|
|
|
|
### 8.1 The new architectural rule for the root `CLAUDE.md`
|
|
|
|
> **Every user-generated surface is reportable by construction.** A new content table added to `SOFT_DELETE_TABLES` that is visible to anyone other than its author MUST be registered in `database/moderation.py` `REPORTABLE_TARGETS`, MUST resolve in `resolve_object_url`, and MUST include `_report_button.html` in its action bar. The registry completeness test enforces the first two; the template coverage test enforces the third.
|
|
|
|
### 8.2 The positioning change
|
|
|
|
`applechanges.md` §5 established that four sites currently promise an uncensored platform, and that this alone is grounds for a 1.2 rejection. The design changes them in step so that marketing, terms and behaviour state the same thing:
|
|
|
|
| Site | Current | Proposed |
|
|
|------|---------|----------|
|
|
| `main.py:744` site description | "…in an open, uncensored environment." | "…in an open environment built by developers, for developers." |
|
|
| `templates/base.html:9` meta description | same string | same replacement |
|
|
| `templates/landing.html:120` hero | same string | same replacement |
|
|
| `templates/landing.html:134` feature card | "No Censorship" | "No Gatekeeping" - with body copy stating that DevPlace does not editorialise technical opinion, and that a short list of prohibited categories is enforced, linking the community guidelines |
|
|
| `database/schema.py:280` default `site_tagline` | same string | same replacement |
|
|
|
|
This is the one item in this document that changes the product's public voice rather than its capabilities. It is presented as a decision, not an assumption, and it is the single change with the highest effect on the outcome of review.
|
|
|
|
---
|
|
|
|
## 9. Sequencing
|
|
|
|
Six phases. Each phase is independently shippable, leaves the platform working, and ends with the full suite (`make test`, all three tiers) green. No phase depends on a later one.
|
|
|
|
| Phase | Contents | Requirements closed |
|
|
|-------|----------|---------------------|
|
|
| **1. Foundation** | `database/moderation.py` registry and constants; `content_reports`, `moderation_actions`, `content_maturity`, `user_consents` tables; `users` columns; `site_settings` keys; `SOFT_DELETE_TABLES` registration; `resolve_object_url` extension; the registry completeness test | substrate for R4-R8, R13-R16 |
|
|
| **2. Reporting and moderation** | `services/moderation/` queue; `routers/reports.py`; `routers/admin/moderation.py`; enforcement routes; `_report_button.html` at all fourteen sites; `_report_dialog.html` + `ReportDialog.js`; `admin_moderation.html` + sidebar; SLA badge; audit keys; Devii actions; API docs | **R5, R6, R7, R8, R9, P1, P2, P3** |
|
|
| **3. Legal and contact** | The seven docs pages; footer links; contact settings; the positioning rewording | **R1, R3, R10, R11, R18, R22** |
|
|
| **4. Consent, terms, age** | Signup terms + date of birth; re-acceptance middleware; consent routes and privacy tab; the AI gateway consent gate; activity-recording consent and indicator | **R2, R13, R15, R16, C8, C9** |
|
|
| **5. Deletion** | `routers/profile/delete.py`; the stamped cascade; `devplace accounts prune`; devRant re-point; the confirmation page | **R12** |
|
|
| **6. Filter, maturity, index, posture** | `services/moderation/filter.py` at the five choke points; `content_maturity` + `_maturity_gate.html`; `/workspaces/index`; IPv6 verification; demo account; review notes; questionnaire and privacy-label answers | **R4, R14, R19, R20, R21, R23, R24, C5** |
|
|
|
|
Phases 2 and 3 together answer the guideline that actually rejects apps. Phase 5 answers the guideline that most often rejects them on the second attempt. Nothing is deferred to "later"; six phases is the whole scope.
|
|
|
|
---
|
|
|
|
## 10. Test plan
|
|
|
|
Following the tier rules in `tests/CLAUDE.md`: tier is decided by fixtures, path mirrors the URL for `api`/`e2e` and the module for `unit`.
|
|
|
|
**`tests/unit/database/moderation.py`**
|
|
- The registry completeness invariant (§11.1) - the single most important test in this feature.
|
|
- `REPORT_REASONS` keys are stable and cover every guideline category.
|
|
- `resolve_object_url` returns a non-`/feed` URL for every registry entry.
|
|
- Filter classification: property checks over the category rule set, asserting monotonicity of score against rule matches and that `verdict` never weakens as mode strengthens.
|
|
- Age-band derivation across the full date domain, including leap days and the exact boundary.
|
|
|
|
**`tests/api/reports/*.py`**
|
|
- Report every registry target type; assert one row, correct `owner_uid`, correct audit event.
|
|
- Duplicate report from the same reporter updates rather than duplicates; from a different reporter creates a second row.
|
|
- Guests are refused; suspended users are refused posting but permitted reporting and deletion.
|
|
- `/reports/mine` shows outcomes; a reporter never sees another reporter's report.
|
|
|
|
**`tests/api/admin/moderation.py`**
|
|
- Queue ordering is oldest-open-first; SLA badge flips at the configured hour.
|
|
- Every `MODERATION_ACTIONS` decision writes a `moderation_actions` row, an audit row, and a notification.
|
|
- The seniority guard blocks a junior admin actioning a senior one and audits `result="denied"`.
|
|
|
|
**`tests/api/profile/delete.py`**
|
|
- Deletion requires the correct password; wrong password does not delete.
|
|
- After deletion the account is unreachable, sessions are revoked, content is gone from every listing.
|
|
- Restore within the grace window from `/admin/trash` restores the whole event under one stamp.
|
|
- After the grace window `purge_event` removes every row and no personal data remains in any table.
|
|
|
|
**`tests/api/auth/terms.py`, `tests/api/profile/consent.py`**
|
|
- Signup without acceptance or below the minimum age fails with a rendered message.
|
|
- Bumping `terms_version` forces re-acceptance on the next mutating request and never on a read.
|
|
- A gateway user-content call without `ai_third_party` consent is refused; with consent it proceeds; withdrawal takes effect immediately.
|
|
|
|
**`tests/e2e/`**
|
|
- **Coverage test:** for each of the fourteen include sites, load the page and assert a report control is present and reachable. This is the test that keeps R5 true over time.
|
|
- Report a post end to end through the dialog; confirm the toast, the notification and the queue row.
|
|
- Block from a comment action bar; confirm the author's content disappears from the feed.
|
|
- Delete an account through the UI and confirm the login no longer works.
|
|
- The maturity interstitial hides labelled content and reveals it only on explicit opt-in.
|
|
|
|
**Rigorous verification (root `CLAUDE.md`, four-layer procedure).** Suspension state, consent state and the deletion cascade are all read-then-write mutations reachable from HTTP, Devii, the devRant API and the CLI, so the procedure applies in full and is not optional:
|
|
|
|
1. **Property checks** over the filter score function and the age-band function across their whole input domain.
|
|
2. **Stateful fuzzing** of report → decide → suspend → lift → delete sequences against a temp DB, asserting after every action that a report never leaves its state machine, a suspension never outlives its expiry, consent history is never rewritten, and no user is ever both deleted and active.
|
|
3. **Concurrency with real separate OS processes**: concurrent decisions on one report must produce exactly one `moderation_actions` row; concurrent deletion requests must produce exactly one cascade. Both are closed with a single atomic conditional `UPDATE … WHERE` at the chokepoint, checked through `db.executable.execute(text(...)).rowcount`, per the standing rule. **Every new column added in §3.5 is written at insert time for new rows and `COALESCE`d in every precondition and arithmetic update**, because a column absent from a row's original `INSERT` is SQL `NULL`, and `NULL = 0` is `NULL`, not true - the exact trap the root `CLAUDE.md` records.
|
|
4. **`pyflakes` / `ruff check`** on every touched file, catching the in-function import that neither a clean compile nor a clean app import would.
|
|
|
|
---
|
|
|
|
## 11. Proof of solidity
|
|
|
|
`apple.md` asks for a mathematical proof that the implementation is solid. A design cannot be proved correct in the abstract; what can be proved is that **coverage is total and stays total**. Three claims, each discharged by a mechanism rather than by diligence.
|
|
|
|
### 11.1 Claim 1 - surface coverage is total, and remains total
|
|
|
|
Let `U` be the set of externally-visible user-generated surfaces, `R` the set of `REPORTABLE_TARGETS` keys, `T` the set of tables in `SOFT_DELETE_TABLES`, and `V ⊆ T` those visible beyond their author.
|
|
|
|
The design requires `V ⊆ R` and enforces it with a unit test that computes `V` from `SOFT_DELETE_TABLES` minus an explicit, reviewed exclusion list of owner-private tables, and asserts the inclusion. A developer adding a UGC table without registering it **fails the suite**.
|
|
|
|
Since the report route, the report partial, the Devii action enum, the API docs enum and the admin filter all derive from `R`, coverage of every consumer follows from `V ⊆ R` by construction. Requirement **R5** is therefore not "implemented on sixteen surfaces" but *closed under future additions* - which is the only form of this guarantee worth having, because 1.2 rejections happen on the surface someone forgot.
|
|
|
|
Formally: coverage is the composition `V ↪ R → {route, partial, action, docs, filter}`. The inclusion is test-enforced; the maps are total functions over `R`; therefore the composition is total over `V`. ∎
|
|
|
|
### 11.2 Claim 2 - every Apple requirement maps to a named artifact
|
|
|
|
The map `requirement → artifact` below is total over the mandatory register and over every triggered conditional. No requirement lacks an artifact; no artifact exists without a requirement.
|
|
|
|
| Req | Artifact | Phase |
|
|
|-----|----------|-------|
|
|
| R1 | `/docs/terms.html` + `terms_version` | 3 |
|
|
| R2 | `SignupForm.accept_terms`, `users.terms_version`, re-acceptance middleware | 4 |
|
|
| R3 | `/docs/community-guidelines.html` from `REPORT_REASONS` | 3 |
|
|
| R4 | `services/moderation/filter.py` at five choke points | 6 |
|
|
| R5 | `REPORTABLE_TARGETS` + `/reports/{target_type}/{target_uid}` + `_report_button.html` | 1, 2 |
|
|
| R6 | `/admin/moderation` + `moderation_actions` | 2 |
|
|
| R7 | `moderation_sla_hours` + the SLA badge + `/docs/content-moderation.html` | 2, 3 |
|
|
| R8 | `/admin/users/{uid}/suspend`, `/ban`, `/lift` + `is_suspended` | 2 |
|
|
| R9 | existing `routers/relations.py` + Block in `_report_button.html` | 2 |
|
|
| R10 | `/docs/contact.html` from `contact_*` settings + footer | 3 |
|
|
| R11 | `/docs/privacy.html` + footer + ASC metadata | 3 |
|
|
| R12 | `routers/profile/delete.py` + stamped cascade + `devplace accounts prune` | 5 |
|
|
| R13 | `SignupForm.birth_date` → `users.age_band` + `moderation_minimum_age` | 4 |
|
|
| R14 | `content_maturity` + `_maturity_gate.html` + `mature_opt_in` | 6 |
|
|
| R15 | `user_consents.ai_third_party` + the gateway gate | 4 |
|
|
| R16 | `POST /profile/{username}/consent` + the privacy tab | 4 |
|
|
| R17 | existing `notification_preferences` (verified, documented) | 6 |
|
|
| R18 | `intellectual_property` reason + `/docs/intellectual-property.html` | 3 |
|
|
| R19 | demo account + review notes | 6 |
|
|
| R20 | questionnaire answered from R4/R5/R6/R13 | 6 |
|
|
| R21 | privacy labels derived from R15's disclosure | 6 |
|
|
| R22 | `contact_*` settings ≡ ASC trader data | 3 |
|
|
| R23 | IPv6 verification of app, nginx, WebSockets, ingress | 6 |
|
|
| R24 | architecture statement in docs + review notes | 6 |
|
|
| R25 | every control exposed as JSON by A4 | 1-6 |
|
|
| C4 | contest position documented | 3 |
|
|
| C5 | `/workspaces/index` | 6 |
|
|
| C8 | `activity_recording` consent + indicator | 4 |
|
|
| C9 | per-instance consent before data reaches user software | 4 |
|
|
| P1 | `moderation_actions` + SLA metrics | 2 |
|
|
| P2 | audit `moderation` category + `moderation_actions` | 2 |
|
|
| P3 | statement of reasons via `create_notification` | 2 |
|
|
| P4 | privacy-label step added to the feature workflow | 6 |
|
|
| P5 | release-notes discipline | 6 |
|
|
|
|
C1, C2, C3, C6 and C7 are untriggered and the design introduces nothing that triggers them: no social login, no payment path, no purchasable randomness, no advertising, no cross-app tracking. Keeping them untriggered is itself recorded as a constraint in the root `CLAUDE.md` rule of §8.1's neighbourhood.
|
|
|
|
### 11.3 Claim 3 - the design introduces no inconsistency
|
|
|
|
Consistency is checked against every convention the repository enforces:
|
|
|
|
| Convention | How this design satisfies it |
|
|
|-----------|------------------------------|
|
|
| Polymorphic `(target_type, target_uid)` | `content_reports`, `content_maturity` use it verbatim |
|
|
| Registry over literal | `REPORTABLE_TARGETS` beside `VOTABLE_TARGETS` |
|
|
| Soft delete everywhere, one stamp per cascade | All four new tables registered; deletion uses one stamp |
|
|
| Runtime policy in `site_settings` | Eleven new keys, zero new constants |
|
|
| Four faces per route | Every route has HTML, JSON, Devii action, API docs |
|
|
| Shared `templates` instance, partial reuse | One partial, one dialog, fourteen includes |
|
|
| ES6 module, one class per file, on `app` | `ReportDialog.js` |
|
|
| Design tokens, no literals | Report and SLA styling uses existing tokens and `--z-*` bands |
|
|
| No comments, no docstrings | The design specifies none |
|
|
| Author attribution at the top of every file | Every new file |
|
|
| European dates, UTC storage | `local_dt` / `dt_ago` for every timestamp shown |
|
|
| Owner-or-admin, seniority guard | `is_owner`, `_is_senior_admin` reused unchanged |
|
|
| `CONFIRM_REQUIRED` with a declared `confirm` param | Four gated Devii tools |
|
|
| Batch helpers, never N+1 | `get_maturity_by_targets`, denormalised `owner_uid` |
|
|
| Never fail silently | Filter fails to `review`; report submission never swallows |
|
|
| No forbidden name patterns, no em-dash | Enforced at authoring and by `/validate` |
|
|
|
|
Zero new patterns are introduced. Every mechanism in this design is an existing DevPlace mechanism applied to a new target set. That is the sense in which it is DRY, and the sense in which it is consistent. ∎
|
|
|
|
---
|
|
|
|
## 12. Verification loop
|
|
|
|
`apple.md` asks that the former steps be repeated recursively until the result is proved solid. Three passes were run over `applecomp.md` → `applechanges.md` → this document. Each pass fed a correction back into the earlier documents, which are the corrected versions.
|
|
|
|
**Pass 1 - requirement completeness.** The first register covered guideline 1.2 and 5.1.1 only. Re-reading the guidelines against the platform's actual feature list added: 4.7 in full (the AI assistant is a chatbot under it, and it restates the 1.2 quartet), 2.5.2 and its educational exception (the container platform), 2.5.14 (presence and session recording), 4.7.4 (the software index), 2.5.5 (IPv6), 5.3 (Code Farm Eras), 6.1's 2025 age-rating overhaul and 6.4's DSA trader status. **Nine requirements were missing from the first draft.** They are R23, R24, C5, C8, C9, and the metadata requirements R20-R22, and the 5.3 position in C4.
|
|
|
|
**Pass 2 - surface completeness.** The first gap analysis listed eight UGC surfaces from the routers. Re-deriving the list from `SOFT_DELETE_TABLES` rather than from the routers produced **twenty**, including four that a router-first reading misses entirely: awards, poll options, quiz options and workspace-served content. That correction is what forced A1 and A2, and therefore the registry, and therefore the completeness invariant of §11.1. A per-surface design would have shipped incomplete.
|
|
|
|
**Pass 3 - consistency and caveat elimination.** Re-reading the design against the conventions produced five corrections, each removing a caveat rather than documenting one:
|
|
|
|
1. Maturity was originally a column on each content table - twenty migrations and a permanent drift risk. Replaced by the polymorphic `content_maturity` table with a batch helper, matching `reactions`.
|
|
2. The filter was originally to be called from each router - twenty call sites. Replaced by five existing choke points in `content.py`, `messages.py` and the profile/signup path.
|
|
3. AI consent was originally a per-feature toggle, which would have needed a gate in every AI consumer. Replaced by one gate at the gateway, with the existing toggles demoted to preferences - no existing preference is flipped and no consumer changes.
|
|
4. Account deletion was originally an immediate hard purge, which conflicts with `/admin/trash`, with the audit trail, and with accidental loss. Replaced by an immediate anonymisation plus a stamped soft-delete event and a GC purge, which is both the compliant behaviour and the behaviour the codebase already has primitives for.
|
|
5. Legal pages were originally new routes. Replaced by `DOCS_PAGES` entries, which brings role gating, SEO, the search index and the export for free, and adds no routing.
|
|
|
|
**Pass 4 - factual re-verification against the source tree.** Every file reference, line number and count asserted across all three documents was re-read from the source rather than trusted. Three errors were found and corrected in place:
|
|
|
|
1. `SOFT_DELETE_TABLES` was stated as 46 tables in `applechanges.md` §3 and in A5 above; the real count, computed from `database/soft_delete.py`, is **44**.
|
|
2. `applechanges.md` §8's mandatory-requirement tally summed to 26 across 25 requirements, because R2 was counted as both missing and partial. Corrected to a true partition: 1 present, 5 partial, 15 missing, 2 blocked, 1 unverified, 1 out of scope.
|
|
3. The conditional tally said "4 not triggered … (C1, C2, C3, C6, C7 - five, counting C7)". Corrected to 5 not triggered, 3 missing, 1 borderline.
|
|
|
|
Everything else verified exactly: `main.py:744`, `templates/base.html:9`, `landing.html:120` and `:134`, `schema.py:276`/`:280`/`:1823`, `soft_delete.py:7`, `ranking.py:11`, `reactions.py:17`, `content.py:197`/`:361`, `database/content.py:22`, `models.py:51`/`:408`, `admin/users.py:179`, `devrant/auth.py:189`, `messages.py:245`, `notifications.py:68`, `admin_base.html:11`-`59`, and the existence of all fourteen include-site templates plus `routers/profile/index.py`, `services/audit/categories.py`, `services/devii/actions/spec.py` and `docs_api/_shared.py`.
|
|
|
|
**Pass 5 - fixed point.** A fifth pass over all three documents produced no further correction: every mandatory requirement maps to an artifact (§11.2), every artifact maps to a requirement, every surface is covered by construction (§11.1), and every convention is satisfied (§11.3). The documents are consistent with each other and with the source tree as read. The loop has converged.
|
|
|
|
**The one open decision** deliberately left to the lord, because it is a product-voice decision and not a technical one, is §8.2: the rewording of the four "uncensored" sites. Everything else in this design is fully specified and requires no further input.
|
|
|
|
---
|
|
|
|
## 13. What approval authorises
|
|
|
|
Approving this document authorises implementation of phases 1 through 6 in §9, in order, each phase validated with `python -c "from devplacepy.main import app"`, per-language manual checks, `ruff check` / `pyflakes` on every touched file, the four-layer rigorous verification of §10 where it applies, and the **full test suite (`make test`, all three tiers, every test) green before the phase is considered done**.
|
|
|
|
Documentation updated in step: `README.md`, the root `CLAUDE.md` (one new rule, §8.1), `devplacepy/routers/CLAUDE.md`, a new `devplacepy/services/moderation/CLAUDE.md`, `devplacepy/database/CLAUDE.md`, `devplacepy/templates/CLAUDE.md`, `events.md`, and the seven new docs pages.
|