DevPlace: App Store compliance implementation design
Author: retoor retoor@molodetz.nl
Stage three of apple.md. Inputs are applecomp.md (what Apple requires) and 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:
content.create_content_item(content.py:197) - posts, projects, gists, news, quizzes.content.create_comment_record(content.py:361) - every comment on every parent type.content.edit_content_itemandcontent.edit_comment_record- edits, so a clean post cannot be edited into a violation.routers/messages.py:245send_messageand the WebSocket send path - direct messages.routers/profile/index.pyprofile update androuters/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, notblock. A flagged item is published and a system report is raised. Nothing legitimate is ever suppressed by a machine. - Only the
sexualandexploitativecategories default toblock, 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 toallow(A9). If classification raises, the content is published and acriticalsystem 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):
- Stamp
deletion_requested_at, revoke every session and access token, invalidate the user cache. soft_delete_in(table, "user_uid", [uid], deleted_by=uid, stamp=stamp)across every table inSOFT_DELETE_TABLESthat carries auser_uid- one stamp, so/admin/trashcan restore the entire event atomically within the grace window.- Anonymise the
usersrow 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. - A GC sweep (
devplace accounts prune, and a scheduled pass in the existing service manager) hard-purges the stamped event afteraccount_deletion_grace_hours, usingpurge_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_partyconsent 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.pyexportingMODERATION_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_userandban_userjoinCONFIRM_REQUIREDindispatcher.py, each declaring aconfirmboolean param in its catalog spec - the load-bearing detail the rootCLAUDE.mdcalls out, without which a gated tool loops forever. - API docs -
docs_api/groups/moderation.py, a new group withendpoint()entries andsample_responsefor every route above, plus the reason enum sourced fromREPORT_REASONS. - SEO - legal pages are public and indexable, added to
routers/seo.py's sitemap;/reports/*and/admin/moderation/*arenoindex,nofollowviabase_seo_context. - Audit - new keys in
events.mdandservices/audit/categories.pycategory_forunder a newmoderationcategory: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.mdgains one new architectural rule (§8.1 below);services/moderation/CLAUDE.mdandrouters/CLAUDE.mdcarry 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_TABLESthat is visible to anyone other than its author MUST be registered indatabase/moderation.pyREPORTABLE_TARGETS, MUST resolve inresolve_object_url, and MUST include_report_button.htmlin 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_REASONSkeys are stable and cover every guideline category.resolve_object_urlreturns a non-/feedURL for every registry entry.- Filter classification: property checks over the category rule set, asserting monotonicity of score against rule matches and that
verdictnever 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/mineshows 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_ACTIONSdecision writes amoderation_actionsrow, 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/trashrestores the whole event under one stamp. - After the grace window
purge_eventremoves 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_versionforces re-acceptance on the next mutating request and never on a read. - A gateway user-content call without
ai_third_partyconsent 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:
- Property checks over the filter score function and the age-band function across their whole input domain.
- 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.
- Concurrency with real separate OS processes: concurrent decisions on one report must produce exactly one
moderation_actionsrow; concurrent deletion requests must produce exactly one cascade. Both are closed with a single atomic conditionalUPDATE … WHEREat the chokepoint, checked throughdb.executable.execute(text(...)).rowcount, per the standing rule. Every new column added in §3.5 is written at insert time for new rows andCOALESCEd in every precondition and arithmetic update, because a column absent from a row's originalINSERTis SQLNULL, andNULL = 0isNULL, not true - the exact trap the rootCLAUDE.mdrecords. pyflakes/ruff checkon 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:
- Maturity was originally a column on each content table - twenty migrations and a permanent drift risk. Replaced by the polymorphic
content_maturitytable with a batch helper, matchingreactions. - The filter was originally to be called from each router - twenty call sites. Replaced by five existing choke points in
content.py,messages.pyand the profile/signup path. - 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.
- 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. - Legal pages were originally new routes. Replaced by
DOCS_PAGESentries, 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:
SOFT_DELETE_TABLESwas stated as 46 tables inapplechanges.md§3 and in A5 above; the real count, computed fromdatabase/soft_delete.py, is 44.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.- 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.