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. pending compares exactly what the application compares: users.terms_version against get_setting("terms_version", "1") or "1" for terms, the ledger row's version for privacy, the latest state for the other three. The or "1" is load-bearing: an admin settings save can write terms_version = "", and a bare get_setting would make every account pending forever.
  • Order by created_at DESC, id DESC, never one of the two. That pair is what database.consent_state selects, so it is the expression the gate evaluates. consent_view on the privacy tab orders by created_at alone; that is the display path, not a gate. Never introduce a third ordering.
  • Build the live-account clauses with has_column. init_db ensures terms_version, terms_accepted_at and deletion_requested_at on users, but not is_active - that column is created implicitly the first time a suspension or a deletion writes it, so a hardcoded reference raises no such column on 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, via db.executable.execute(text(...), params).rowcount inside with db:, exactly like deletion.claim_deletion. Sixteen real processes racing one account produce exactly one ledger row and one audit row.
  • Never write updated_at on users. That is why database.atomic.conditional_update_row cannot be reused here: it appends updated_at unconditionally, 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, including withdrawn_at = '' rather than NULL on the unused side. A unit test compares the two field by field. It is not set_consent itself only because set_consent cannot express a precondition or join a caller's transaction.
  • One cache bump per run, not one per account. clear_user_cache propagates a global auth version bump that makes every worker drop its whole user cache; run_once bumps once at the end, and only when a gate column actually changed.
  • The audit row is deliberate. It uses the existing terms.accept and consent.grant keys with actor_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_api entry. The surface is the generic services admin, exactly as for NotificationRelayService and AuditService.
  • 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 terms to every account would poison the moderation tests that assert the gate refuses. Convergence is covered by unit tests calling converge_user directly.