CLAUDE.md
This file documents the acceptance convergence subsystem (devplacepy/services/acceptance/). Claude Code auto-loads it whenever a file under this directory is read or edited. The full design record is accept.md at the repository root.
Why this subsystem exists
An operator running a production-identical instance for extended manual testing is otherwise taxed forever by the platform's own safety controls: five consents, a versioned terms gate on every mutating request, and every account predating the trust-and-safety commit reading terms_version = NULL because init_db deliberately never backfills it. This service converges each account onto the acceptance state a real population would have produced itself, so the instance stays production byte for byte while nobody has to click the same dialog again.
It is off by default and it is never appropriate on a real production host.
The two load-bearing ideas
The application must not know. There is no request-path branch, no schema, no route, no template, no Jinja global, no Devii tool and no environment flag. The only import of this package anywhere is the one registration line in main.py, and tests/unit/services/acceptance/isolation.py fails the suite if a second one appears. An environment flag would be exactly the knowledge the application is not allowed to have, which is why there is none.
The decline register needs no storage. user_consents is append-only in effect, so the latest live row for a (user, kind) pair already is the register. The service only ever writes granted; it follows that any withdrawn row in the ledger was written by a human, and the service never touches that pair again. No provenance column, no marker, no flag, and nothing for the application to observe.
Module map
| File | Owns |
|---|---|
agreements.py |
Agreement, AGREEMENTS, setting_key, label_for, agreement_for |
pending.py |
latest_consent, not_withdrawn, satisfied_clause, live_account_clauses, current_version, pending |
grant.py |
converge_user plus the two private claim shapes and the audit call |
service.py |
AcceptanceService: config fields built from the registry, run_once, collect_metrics |
pending.py and grant.py import only devplacepy.database and sqlalchemy at module top; generate_uid and the audit recorder are imported lazily inside the functions that use them, mirroring services/moderation/deletion.py.
The registry is the completeness guarantee
AGREEMENTS annotates database.CONSENT_KINDS with two facts: which site_settings key holds the policy version, and which users column the application's own gate reads. terms is the only agreement with a gate column, because needs_acceptance reads users.terms_version and not the ledger; privacy is versioned but ledger-only, matching VERSION_KEYS in routers/profile/consent.py.
A unit test asserts {a.kind for a in AGREEMENTS} == set(CONSENT_KINDS). A sixth consent fails the suite until it is classified here, and it then appears in the admin form with no edit to the service, because the per-agreement config fields are built from the registry rather than written out by hand.
Rules that must not regress
- Satisfaction is the gate's own expression, never a proxy.
pendingcompares exactly what the application compares:users.terms_versionagainstget_setting("terms_version", "1") or "1"forterms, the ledger row'sversionforprivacy, the latest state for the other three. Theor "1"is load-bearing: an admin settings save can writeterms_version = "", and a bareget_settingwould make every account pending forever. - Order by
created_at DESC, id DESC, never one of the two. That pair is whatdatabase.consent_stateselects, so it is the expression the gate evaluates.consent_viewon the privacy tab orders bycreated_atalone; that is the display path, not a gate. Never introduce a third ordering. - Build the live-account clauses with
has_column.init_dbensuresterms_version,terms_accepted_atanddeletion_requested_atonusers, but notis_active- that column is created implicitly the first time a suspension or a deletion writes it, so a hardcoded reference raisesno such columnon an instance where nobody was ever suspended. An absent column means no account can be in that state, so omitting the clause is the correct answer. - Every write is one conditional statement decided on the driver's real
rowcount, viadb.executable.execute(text(...), params).rowcountinsidewith db:, exactly likedeletion.claim_deletion. Sixteen real processes racing one account produce exactly one ledger row and one audit row. - Never write
updated_atonusers. That is whydatabase.atomic.conditional_update_rowcannot be reused here: it appendsupdated_atunconditionally, the table has no such column, and creating one would make the service's rows distinguishable from the route's. - The ledger insert must stay byte-compatible with
set_consent, includingwithdrawn_at = ''rather thanNULLon the unused side. A unit test compares the two field by field. It is notset_consentitself only becauseset_consentcannot express a precondition or join a caller's transaction. - One cache bump per run, not one per account.
clear_user_cachepropagates a globalauthversion bump that makes every worker drop its whole user cache;run_oncebumps once at the end, and only when a gate column actually changed. - The audit row is deliberate. It uses the existing
terms.acceptandconsent.grantkeys withactor_kind="service",actor_username="acceptance". The audit log is the operator's record and no code path reads it, so it costs nothing in invisibility and is the only trace distinguishing a converged acceptance from a human one.
What is deliberately not an agreement
| Excluded | Why |
|---|---|
users.age_band |
A declaration of fact, not an agreement. Fabricating a declared age would silently unlock restricted content for an account that declared 13-15. Sign the test account up with an adult date of birth instead. |
users.mature_opt_in |
A preference gated by the age band, with no ledger row and therefore no decline register. The site setting moderation_mature_default_hidden already turns the interstitial off instance-wide. |
| Guest consents | Nothing writes a consent row with owner_kind = "guest", and the gateway resolves a guest to owner kind anonymous, which consent_denied exempts by design. |
Preferences (interactions_enabled, notifications, customization) |
None of them blocks anything. |
The terms asymmetry, and the one operator step
POST /auth/accept-terms grants two agreements: terms and privacy. The service keeps them independent on purpose, because the per-agreement switches exist for edge-case testing; enabling both reproduces the human path exactly, enabling one is a deliberate divergence.
Withdrawing the terms consent does not clear users.terms_version, so a tester who wants to exercise the gate withdraws their own terms consent and then has an administrator bump terms_version at /admin/settings. Every other account converges within one interval; the tester stays gated indefinitely.
Rules for extending this
- A new consent kind: classify it in
AGREEMENTS(the test forces this), and nothing else. - Never add a route, a schema, a template, a Devii tool or a
docs_apientry. The surface is the generic services admin, exactly as forNotificationRelayServiceandAuditService. - Never add a second decline register, a second ordering, or an environment check.
- Never write a persisted test that enables the service and asserts convergence: the suite is serial against one seeded database, and granting
termsto every account would poison the moderation tests that assert the gate refuses. Convergence is covered by unit tests callingconverge_userdirectly.