diff --git a/CLAUDE.md b/CLAUDE.md index 7552d8b2..c8f222de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i | `devplacepy/services/openai_gateway/CLAUDE.md` | AI gateway: `/openai/v1/*`, usage ledger, provider/model routing | | `devplacepy/services/jobs/CLAUDE.md` | Async job services: zip, fork, SEO diagnostics, SEO metadata, DeepSearch, AI Usage Analyzer | | `devplacepy/services/moderation/CLAUDE.md` | Trust and safety: the reportable-target registry, the content filter and its five choke points, the report queue and its atomic resolution, enforcement, consent, maturity, account deletion | +| `devplacepy/services/acceptance/CLAUDE.md` | Acceptance convergence: the opt-in service that grants every policy agreement to every account that has not declined it, its invisibility contract and the ledger-as-decline-register rule | | `devplacepy/services/audit/CLAUDE.md` | Audit log: recorders, categories, retention | | `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download | | `devplacepy/services/telegram/CLAUDE.md` | Telegram bot bridge | diff --git a/README.md b/README.md index e4e9dfb5..c1e9bf04 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ and its full configuration are documented automatically - including future servi - **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected - **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user - **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image +- **`AcceptanceService`** - grants every policy agreement (Terms of Service, Privacy Policy, third-party AI processing, activity recording, container credentials) to every account that has not declined it, so an instance kept production-identical for extended manual testing never interrupts with an acceptance dialog. Administrator-only, **off by default**, with a separate switch per agreement type and a dry run that reports what it would do without writing. An account that withdrew a consent is never granted it again, with no further action: the consent ledger itself is the decline register. It is not appropriate on a real production host ### Container manager (admin only) diff --git a/accept.md b/accept.md new file mode 100644 index 00000000..9b77213e --- /dev/null +++ b/accept.md @@ -0,0 +1,769 @@ +# DevPlace: acceptance convergence implementation design + +Author: retoor + +Design input is [`lensfl.md`](lensfl.md). This document specifies a background service that converges every +account onto every policy agreement the platform can ask for, so that an operator running a +production-identical instance for manual testing is never interrupted by an acceptance dialog, a consent +refusal, or the terms gate. + +Nothing here is implemented. This is the specification. + +Every claim below about existing behaviour was read out of the source at the time of writing, and the +citations are exact. The subsystem this service operates on is documented in +[`devplacepy/services/moderation/CLAUDE.md`](devplacepy/services/moderation/CLAUDE.md); the framework it is +built on is documented in [`devplacepy/services/CLAUDE.md`](devplacepy/services/CLAUDE.md). Read both before +implementing. + +--- + +## 1. The problem, stated exactly + +DevPlace enforces five consents, a versioned terms gate on every mutating request, and an age-banded +maturity gate. Each is correct and each must stay. On a production-identical instance used for extended +manual testing they produce a permanent tax: every new account, every bot in the fleet, and every account +that predates the trust-and-safety commit (whose `users.terms_version` is deliberately left `NULL`, never +backfilled) is blocked or degraded until a human clicks through the same dialogs again. + +The instance must remain production. Not production-like with a flag threaded through the request path; +production, byte for byte, with one background process quietly converging the acceptance state that a real +population of users would have produced themselves. + +Three constraints follow, and they are the whole design: + +1. **The application must not know.** No request-path branch, no schema, no template, no route, no response + field, no Jinja global, no Devii tool. If any consumer could observe the feature, the instance is no + longer a production simulation and the exercise is worthless. +2. **A manual decline must be permanent.** Testing the refusal path is the reason the operator is here. An + account that has declined an agreement must never be converged, ever, with no further operator action. +3. **It must be off, and stay off, unless deliberately switched on.** Two independent switches, both + defaulting to off, plus a dry run that defaults to on. + +--- + +## 2. The invisibility contract + +"The system must not know" is vague. These are the testable clauses that replace it. + +| # | Clause | How it is enforced | +|---|--------|--------------------| +| **I1** | No new table, no new column, anywhere. | The service writes only through existing columns of `users` and existing columns of `user_consents`. A unit test asserts the column sets it writes are subsets of what `database.moderation.set_consent` and `routers/auth/terms.py` already write. | +| **I2** | No new value in any existing registry or enum. `CONSENT_KINDS`, `CONSENT_STATES`, `AGE_BANDS`, `MATURITY_LEVELS`, `REPORTABLE_TARGETS`, `UNREPORTABLE_TABLES`, `SOFT_DELETE_TABLES` are untouched. | Reviewed at implementation; the moderation registry test already fails on an unclassified table, and no table is added. | +| **I3** | No new audit event key. The service records the same two keys the human paths record. | `events.md` gains no key, the header's stated key count does not change and no category count changes; only the "Recorded in" column of `terms.accept` and `consent.grant` gains a file. | +| **I4** | The rows produced are indistinguishable in shape from the rows a real acceptance produces. | A unit test writes one row through `set_consent` and one through the service and compares column sets and value shapes field by field. | +| **I5** | Nothing imports the package except one registration line in `main.py`. | A unit test greps the source tree for `services.acceptance` and asserts exactly one importing module, `devplacepy/main.py`. | +| **I6** | The feature has no route, no schema, no template, no Devii action and no `docs_api` entry. | It inherits the generic services admin surface, exactly as `NotificationRelayService`, `PresenceRelayService`, `LiveViewRelayService` and `AuditService` do. See section 14 for why this does not violate the four-faces rule. | +| **I7** | The test suite is unaffected. | `DEVPLACE_DISABLE_SERVICES=1` in `tests/conftest.py` stops `supervise()` from ever being called, and `default_enabled = False` keeps the service off even when services do run. The moderation tests that assert a `403` from the terms gate stay green by construction. | + +The one thing that is deliberately visible is the audit log. Every state change in DevPlace records one +append-only row, and a service that silently mutated user consent state would be the single worst possible +exception to that rule. The audit log is the operator's record, not application state: no code path reads it +to decide behaviour, so recording there costs nothing in invisibility and buys full traceability. This is +the position `devplacepy/services/audit/CLAUDE.md` already takes. + +--- + +## 3. Design axioms + +Every axiom is an existing DevPlace pattern, named with its precedent. None is invented for this feature. + +| # | Axiom | Precedent | +|---|-------|-----------| +| **A1** | A background subsystem is a `BaseService` with declarative `config_fields`, and gets start/stop, enable-on-boot, run-now, a config form, logs, live status and metrics for free. | `services/base.py`; `ModerationService`, `PushService` | +| **A2** | An opt-in service sets `default_enabled = False` so `is_enabled()` reads `get_setting(enabled_key, "0")` and it never auto-starts on boot. | `services/base.py:203`; `BotsService` | +| **A3** | Runtime policy lives in `site_settings`, read through the field's own `ConfigField.read()`, live-editable, never a code constant. | `services/base.py:218` (`get_config`) | +| **A4** | The target set is a registry, not a literal. Every consumer reads the same structure; adding a type is one entry. | `REPORTABLE_TARGETS`, `CONSENT_KINDS`, `DATA_PATHS`; `PushService.config_fields` splices `*providers.admin_fields()` | +| **A5** | A read-then-write mutation reachable from more than one path is a race until it is one conditional SQL statement decided on the driver's real `rowcount`. | `services/moderation/deletion.py:140` (`claim_deletion`), `queue.claim_open`, `database/atomic.py` | +| **A6** | Lock-owner-only background work; never per worker. | `main.py:289-293`: services are registered in every worker, `supervise()` runs only in the worker that won `acquire_service_lock()` | +| **A7** | Every state change records an audit row, and the recorder never raises into the caller. | `services/audit/record.py:288` (`record_system`) | +| **A8** | A control that cannot evaluate its own precondition fails closed, never open. | `moderation/filter.classify` fails to `review`, never to `allow` | +| **A9** | Legacy rows carry `NULL` in any column added after they were written, and a column added after the table may not exist at all. Every precondition uses `COALESCE`, and every raw-SQL reference to an optional column is guarded by `has_column`. | `claim_deletion`'s `COALESCE(deletion_requested_at, '')`; `deletion.due_purges`'s `if not table.has_column(...)`; the root `CLAUDE.md` race-safety rule | +| **A10** | No silent caps. When a bound truncates the work, say so in the log. | Root `CLAUDE.md`, "Rigorous correctness verification" | + +--- + +## 4. The agreement registry + +An **agreement** is a policy statement the platform asks a member to accept, recorded in `user_consents`. +There are exactly five and they are already enumerated by `database.CONSENT_KINDS` +(`database/moderation.py:122`). The registry does not invent a parallel list; it annotates the existing one +with the two facts the service needs. + +`devplacepy/services/acceptance/agreements.py`: + +```python +@dataclass(frozen=True) +class Agreement: + kind: str + version_setting: str + gate_column: str + stamp_column: str + + +AGREEMENTS: tuple[Agreement, ...] = ( + Agreement("terms", "terms_version", "terms_version", "terms_accepted_at"), + Agreement("privacy", "privacy_version", "", ""), + Agreement("ai_third_party", "", "", ""), + Agreement("activity_recording", "", "", ""), + Agreement("container_credentials", "", "", ""), +) +``` + +`kind` is a key of `CONSENT_KINDS`. `version_setting` is the `site_settings` key holding the policy version, +`""` when the agreement is unversioned. `gate_column` is the `users` column the application's own gate reads, +`""` when the agreement is ledger-only. `stamp_column` holds the acceptance instant beside it. + +`terms` is the only agreement with a gate column, because `routers/auth/terms.needs_acceptance` reads +`users.terms_version` and not the ledger. `privacy` is versioned but ledger-only: `VERSION_KEYS` in +`routers/profile/consent.py:28` maps exactly `terms` and `privacy` to a version setting, and every other kind +resolves to the literal `"1"` through `consent_version`. That asymmetry is in the application, not in this +design; the registry records it rather than papering over it. + +**Completeness is enforced, not remembered.** A unit test asserts +`{a.kind for a in AGREEMENTS} == set(CONSENT_KINDS)`. A sixth consent added to `CONSENT_KINDS` in the future +fails the suite until it is classified here, exactly as `tests/unit/database/moderation.py` does for +reportable tables. This is the single most valuable line in the feature: it is what keeps the convergence +complete as the platform grows. + +The label rendered in the admin form is `CONSENT_KINDS[kind]`, read at field-construction time. The service +never carries its own copy of the wording. + +### 4.1 What is deliberately not an agreement + +| Excluded | Why | The supported answer instead | +|---|---|---| +| `users.age_band` | A declaration of fact, not an agreement. Fabricating a declared age is not a production simulation, it is a falsification, and it would silently unlock `restricted` content for accounts that declared 13-15. | Sign the test account up with an adult date of birth. | +| `users.mature_opt_in` | A content preference gated by the age band, with no entry in the consent ledger and therefore no decline register that outlives the 90-day `audit_log_retention_days` window. Adding a second, weaker decline mechanism for one field would violate DRY and clause I2. | The existing site setting `moderation_mature_default_hidden`, editable at `/admin/settings`, already turns the interstitial off instance-wide. | +| Guest consents | Nothing writes a consent row with `owner_kind = "guest"` and no guest surface is gated by one. The gateway resolves a guest to owner kind `anonymous`, which `consent_denied` exempts by design. | None needed. | +| `interactions_enabled`, notification preferences, customization toggles | Per-user preferences, not policy acceptances. None of them blocks anything. | None needed. | + +Each exclusion is a boundary, not an oversight, and each is restated in the nested `CLAUDE.md`. + +--- + +## 5. The decline register + +This is the load-bearing idea and it needs no storage at all. + +`user_consents` is append-only in effect: `set_consent` stamps `withdrawn_at` on the current row when it is +granted and then inserts a new row either way (`database/moderation.py:289`), so the full history is provable. +The **latest live row for a `(user, kind)` pair therefore already is the decline register**: + +| Latest row state | Meaning | Service behaviour | +|---|---|---| +| no row | never asked | converge | +| `granted`, current version | satisfied | nothing to do | +| `granted`, stale version | policy changed since acceptance | converge | +| `withdrawn` | **declined by a human** | never touch, permanently | + +The service only ever writes `granted`. It follows that any `withdrawn` row in the ledger was written by a +human action: the owner-only `POST /profile/{username}/consent`, the Devii consent tool acting on the owner's +behalf, or the CLI. No provenance column, no marker, no flag, and nothing for the application to observe. +Clause I1 is satisfied not by discipline but by the shape of the existing data. + +**One SQL fragment expresses it**, defined once in `pending.py` and used by every read and every write: + +```sql +COALESCE((SELECT c.state FROM user_consents c + WHERE c.owner_kind = 'user' AND c.owner_id = + AND c.kind = :kind AND c.deleted_at IS NULL + ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '') != 'withdrawn' +``` + +**The ordering is not a detail and must not be simplified.** `database.consent_state` selects +`order_by=["-created_at", "-id"]` (`database/moderation.py:274`) and `consent_granted` is a thin wrapper over +it, so that pair *is* the expression the application evaluates when it asks whether a consent stands. +Ordering by `id` alone, or by `created_at` alone, would make the service disagree with the gate in exactly the +tie case that matters: two rows written in the same millisecond, which is precisely what a converge-then- +withdraw race produces. `deleted_at IS NULL` is required because `user_consents` is in `SOFT_DELETE_TABLES`. +The index `idx_user_consents_owner` (`owner_kind`, `owner_id`, `kind`) already exists +(`database/schema.py:1897`) and serves this; no index is added. + +`consent_view` on the profile privacy tab orders by `-created_at` only and takes the first row per kind. That +is a looser tiebreak than `consent_state`, and it is the display path, not a gate. Follow `consent_state`; +never introduce a third ordering. + +### 5.1 Terms is the one case that needs an operator step + +Withdrawing the `terms` consent does not clear `users.terms_version`, so a tester who withdraws it is not +immediately gated. The recipe is two actions, both of which already exist: + +1. The tester withdraws their own `terms` consent once, at `/profile/{username}?tab=privacy`. +2. An administrator bumps `terms_version` at `/admin/settings`. + +Every other account converges within one interval; the tester stays gated indefinitely and can exercise +`/auth/accept-terms`, the `TermsGate.js` in-place dialog, and the `terms_acceptance_required` JSON refusal as +often as they like. This is the workflow section 13 documents for the operator. + +--- + +## 6. Module map + +New package `devplacepy/services/acceptance/`, laid out like `services/moderation/`: registry, read side, +write side, service. + +| File | Owns | +|---|---| +| `__init__.py` | Nothing but the package. No re-exports; the service is imported by path from `main.py`. | +| `agreements.py` | `Agreement`, `AGREEMENTS`, `setting_key(kind)`, `label_for(kind)` | +| `pending.py` | `NOT_WITHDRAWN`, `satisfied_clause(agreement)`, `pending(agreement, limit) -> list[dict]`, `current_version(agreement) -> str` | +| `grant.py` | `converge_user(agreement, user, version) -> bool`, `_claim_gate_column`, `_insert_consent_row`, the audit calls | +| `service.py` | `AcceptanceService(BaseService)`: config fields, `run_once`, `collect_metrics` | +| `CLAUDE.md` | The nested subsystem documentation (section 14) | + +Import discipline, mirroring `services/moderation/deletion.py`: `pending.py` and `grant.py` import only from +`devplacepy.database` and `sqlalchemy`; `devplacepy.services.audit`, `devplacepy.utils.generate_uid` and +`devplacepy.database.bump_cache_version` are imported lazily inside the functions that use them, exactly as +`deletion.py` imports its audit recorder at line 187. Nothing in the package imports `content`, a router, or a +template global. + +--- + +## 7. The read side: what is pending + +`pending(agreement, limit)` returns the bounded list of accounts that are neither satisfied nor declined. +One statement, three shapes of one clause. + +```sql +SELECT u.uid, u.username +FROM users u +WHERE + AND + AND +ORDER BY u.id +LIMIT :limit +``` + +`` is the fragment from section 5, correlated on `u.uid`. + +`` is derived from the registry, and the derivation rule is the principle that keeps this +honest: **satisfaction is defined by the exact expression the application's own gate evaluates, never by a +proxy.** + +| Agreement shape | Application gate | `` | +|---|---|---| +| gate column declared (`terms`) | `needs_acceptance` compares `users.terms_version` to `get_setting("terms_version", "1") or "1"` (`routers/auth/terms.py:21-28`) | `COALESCE(u.terms_version, '') != :version` | +| versioned, ledger only (`privacy`) | the privacy tab compares the latest row's `version` against `consent_version(kind)` | `COALESCE((SELECT c.version FROM user_consents c WHERE ... ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '') != :version` | +| unversioned (the other three) | `consent_granted` asks whether the latest state is `granted` | `COALESCE((SELECT c.state FROM user_consents c WHERE ... ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '') != 'granted'` | + +For the versioned ledger-only shape the state does not appear in the clause: a `withdrawn` row is already +excluded by ``, so comparing the version alone is complete. Keeping the two concerns in two +independent clauses is what makes that provable rather than lucky. + +`current_version(agreement)` is `get_setting(agreement.version_setting, "1") or "1"` for a versioned agreement +and the literal `"1"` for an unversioned one, which is byte-identical to `consent_version` in +`routers/profile/consent.py:47`. **The `or "1"` is load-bearing and is not cosmetic:** on a fresh database an +admin settings save can insert `terms_version = ""`, and a bare `get_setting` would then make every account +pending forever and re-write the same rows on every tick. This is the trap documented in +`services/moderation/CLAUDE.md`, and every reader of a policy version in this codebase carries the guard. + +### 7.1 The live-account clauses are built, not hardcoded + +This is the correction that backwards compatibility actually turns on, and it is stricter than a `COALESCE`. + +`init_db` ensures `terms_version`, `terms_accepted_at` and `deletion_requested_at` on `users` +(`database/schema.py:2000-2015`). **It does not ensure `is_active`.** That column is created implicitly by +`dataset` the first time `enforcement.suspend_user` or `deletion.anonymise` writes it, so on an instance where +nobody has ever been suspended or deleted the column does not exist and a raw-SQL reference to it raises +`OperationalError: no such column`. A background service that dies on a clean database is worse than no +service. + +So the `WHERE` clauses are assembled from what the table actually has, the same way `deletion.due_purges` +guards with `table.has_column` before querying: + +```python +users = get_table("users") +clauses = [] +if users.has_column("deletion_requested_at"): + clauses.append("COALESCE(u.deletion_requested_at, '') = ''") +if users.has_column("is_active"): + clauses.append("COALESCE(u.is_active, 1) != 0") +if users.has_column("deleted_at"): + clauses.append("u.deleted_at IS NULL") +``` + +An absent column means no account can be in that state, so omitting the clause is the correct answer and not +a relaxation. `COALESCE(u.is_active, 1) != 0` matches `database.users.is_account_active` exactly, which treats +`NULL` as active (`database/users.py:77-79`). `users` is not in `SOFT_DELETE_TABLES` - deletion anonymises and +stamps `deletion_requested_at` instead - so the `deleted_at` clause is defensive and will normally be omitted. + +The same rule applies to the gate column: if `agreement.gate_column` is declared but the column is missing, +`pending` treats the agreement as ledger-only for that database rather than raising. + +Guards, in order, at the top of `pending`: + +- `if "users" not in db.tables or CONSENTS_TABLE not in db.tables: return []`. On a database so fresh that the + consent table has not been created, no account can be shown to have declined, so per axiom A8 the answer is + "converge nobody", not "converge everybody". +- `limit = max(1, limit)`. + +`ORDER BY u.id` gives a stable, resumable sweep: a capped run always resumes from the same place, and a large +population converges deterministically over consecutive intervals rather than randomly. + +--- + +## 8. The write side: one conditional statement per grant + +Every write re-evaluates its own precondition inside the write transaction and is decided on the driver's real +`rowcount`. This closes the race between the service's `SELECT` and a member withdrawing a consent from the +profile page a moment later; SQLite serialises writers, so a withdrawal committed before ours is visible to +our `WHERE` clause and the grant simply does not happen. + +`dataset`'s wrapped `db.query()` does not expose `rowcount`. Use +`db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`, exactly as +`deletion.claim_deletion` does at `services/moderation/deletion.py:140`. + +### 8.1 With a gate column (`terms`) + +The users update is the atomic claim; the ledger row is the follow-through, in the same transaction so the two +can never diverge. + +```sql +UPDATE users +SET terms_version = :version, terms_accepted_at = :now +WHERE uid = :uid + AND COALESCE(terms_version, '') != :version + AND COALESCE((SELECT c.state FROM user_consents c + WHERE c.owner_kind = 'user' AND c.owner_id = users.uid + AND c.kind = 'terms' AND c.deleted_at IS NULL + ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '') != 'withdrawn' +``` + +`rowcount == 1` wins the claim; `0` means another worker, a real acceptance, or a withdrawal got there first, +and the function returns `False` without writing anything else. + +The set clause writes exactly the two columns `POST /auth/accept-terms` writes and nothing more +(`routers/auth/terms.py:57-61`). **`updated_at` is not written**, which is also why +`database.atomic.conditional_update_row` cannot be reused here: it appends `updated_at = :updated_at` +unconditionally (`database/atomic.py:16`), the `users` table has no such column, and creating one would make +the service's rows distinguishable from the route's. The hand-rolled statement is the same shape +`claim_deletion` uses on the same table, for the same reason. + +**The human path grants two agreements, not one.** `POST /auth/accept-terms` writes the users row, then calls +`set_consent` for `terms` **and** for `privacy` at `get_setting("privacy_version", "1") or "1"`. The service +deliberately keeps the two agreements independent, because the whole point of the per-agreement switches is +edge-case testing: an operator must be able to converge `terms` while leaving `privacy` pending. Enabling both +agreements reproduces the human path exactly; enabling one is a deliberate, documented divergence and the +nested `CLAUDE.md` says so. + +### 8.2 Ledger only (the other four) + +```sql +INSERT INTO user_consents + (uid, owner_kind, owner_id, kind, version, state, + granted_at, withdrawn_at, created_at, deleted_at, deleted_by) +SELECT :row_uid, 'user', :uid, :kind, :version, 'granted', + :now, '', :now, NULL, NULL +WHERE COALESCE((SELECT c.state FROM user_consents c + WHERE c.owner_kind = 'user' AND c.owner_id = :uid + AND c.kind = :kind AND c.deleted_at IS NULL + ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '') != 'withdrawn' + AND +``` + +`row_uid` is `utils.generate_uid()`. The column list, the values, and the explicit `deleted_at: NULL, +deleted_by: NULL` are copied from `database.moderation.set_consent` (`database/moderation.py:300-315`) and must +stay in step with it; the test in clause I4 is what enforces that. Note `granted_at = :now` and +`withdrawn_at = ''` and not `NULL`: `set_consent` writes the empty string on the unused side, and clause I4 +means matching it. + +**Why this is not `set_consent`.** `set_consent` cannot express a precondition and cannot join a caller's +transaction, so reusing it would reintroduce exactly the race this statement closes and would break the +atomicity of the terms path. The duplication is one INSERT, it is confined to one private function +`_insert_consent_row(uid, kind, version, now)` used by both write shapes, and it is covered by a test that +compares it against `set_consent` field by field. This is a deliberate, documented divergence of the same kind +`claim_deletion` makes against a plain `table.update`. + +### 8.3 Audit + +Only on a won claim, and only with keys that already exist: + +| Agreement | Event key | Fields | +|---|---|---| +| `terms` | `terms.accept` | `target_type="user"`, `target_uid`, `target_label=username`, `new_value=version` | +| the other four | `consent.grant` | as above, `new_value="granted"`, `metadata={"kind": kind}` | + +Both carry `links=[audit.target("user", uid, username)]`, matching the human paths at +`routers/auth/terms.py:82` and `routers/profile/consent.py:84`. + +Recorded through the lazily imported recorder, following `deletion.py:187`: + +```python +from devplacepy.services.audit import record as audit + +audit.record_system( + "terms.accept", + actor_kind="service", + actor_username="acceptance", + target_type="user", + ... +) +``` + +`actor_role="system"` and `origin="service"` are already the defaults of `record_system` +(`services/audit/record.py:288-307`); `service` is a legal actor kind and a legal origin +(`services/audit/categories.py:57-58`). Both keys already exist in `events.md` (lines 69 and 72) and already +map to the `account` category. The recorder routes through `background.submit` and never raises into the +caller. Nothing is added to `events.md` except the new file name in the "Recorded in" column of those two +rows; the key count stated in the file header is unchanged. + +The audit row is the only thing that distinguishes a converged acceptance from a human one, and it lives where +an operator looks and no code path reads. + +### 8.4 Cache invalidation + +`users.terms_version` is on the cached user row and the row is cached per worker for 300s +(`utils/authcache.py:6`), so a converged account would otherwise stay gated on the workers that already cached +it. + +- A gate-column write requires the cross-worker `auth` version bump. +- A ledger-only write does not: `consent_granted` reads the table fresh on every call. + +`clear_user_cache(uid)` bumps the global `auth` version by default (`utils/authcache.py:13-18`), and a version +bump makes **every** worker drop its **whole** user cache. Calling it per account would do that once per +converged user; a run of 200 accounts would do it 200 times. Instead: `run_once` performs exactly **one** +`database.bump_cache_version("auth")` at the end of the run, and only when at least one gate column actually +changed. One bump invalidates every worker's cache including the lock owner's, which is precisely the required +effect, at 1/200th of the cost. Terms acceptance gates writes, so it is an authorization change and must +propagate; it just does not need to propagate 200 times. + +--- + +## 9. The service + +`devplacepy/services/acceptance/service.py`. + +``` +name "acceptance" +title "Acceptance convergence" +default_enabled False +interval_seconds 300 (five minutes, as specified) +min_interval 60 +METRICS_SECONDS 60 +``` + +`description`, shown on `/admin/services` and its detail page, states plainly what it does and that it is for +a production-identical test instance, so an operator who finds it enabled on a real production host knows +immediately that it is wrong. `details` carries the decline-register rule, so the one thing an operator must +understand is on the page they are already looking at. + +### 9.1 Configuration fields + +| Key | Type | Default | Group | Purpose | +|---|---|---|---|---| +| `service_acceptance_enabled` | bool | `0` | General | Framework field. The master switch, off by boot default via `default_enabled = False`. | +| `service_acceptance_interval` | int | `300` | General | Framework field, floored at `min_interval`. | +| `acceptance_dry_run` | bool | `1` | Safety | Compute and log what would be converged; write nothing, record nothing. | +| `acceptance_batch_size` | int | `200` (min 1, max 5000) | Safety | Maximum accounts converged per agreement per run. | +| `acceptance_grant_terms` | bool | `0` | Agreements | One field per registry entry. | +| `acceptance_grant_privacy` | bool | `0` | Agreements | | +| `acceptance_grant_ai_third_party` | bool | `0` | Agreements | | +| `acceptance_grant_activity_recording` | bool | `0` | Agreements | | +| `acceptance_grant_container_credentials` | bool | `0` | Agreements | | + +`setting_key(kind)` is `f"acceptance_grant_{kind}"`, defined once in `agreements.py` and used by the field +list, `run_once` and the tests. The per-agreement fields are **built from the registry**, not written out by +hand, the same way `PushService.config_fields` splices `*providers.admin_fields()`: + +```python +config_fields = [ + ConfigField( + setting_key(agreement.kind), + label_for(agreement.kind), + type="bool", + default=False, + help=CONSENT_KINDS[agreement.kind], + group="Agreements", + ) + for agreement in AGREEMENTS +] + [ ...the two safety fields... ] +``` + +`ConfigField.read()` returns the typed default whenever the setting is unset or unparseable +(`services/base.py:81-89`), so a fresh install reads `False` for every agreement with no seeding in +`init_db` and no change to `operational_defaults`. + +A sixth consent added to `CONSENT_KINDS` therefore appears in the admin form with no edit to the service. This +is what the request asked for: one option in the admin, per type of agreement. + +Three gates stand between a fresh install and a single written row: the service is disabled, every agreement +is disabled, and dry run is on. All three must be changed deliberately. + +### 9.2 `run_once` + +``` +config = self.get_config() +dry = config["acceptance_dry_run"] +limit = config["acceptance_batch_size"] +gate_changed = False +for agreement in AGREEMENTS: + if not config[setting_key(agreement.kind)]: + continue + version = current_version(agreement) + candidates = pending(agreement, limit) + if not candidates: + continue + if dry: + self.log(f"[dry run] {agreement.kind}: {len(candidates)} account(s) would be converged " + f"to version {version}: {sample_names(candidates)}") + continue + granted = 0 + for user in candidates: + if converge_user(agreement, user, version): + granted += 1 + gate_changed = gate_changed or bool(agreement.gate_column) + self.log(f"{agreement.kind}: converged {granted} of {len(candidates)} pending at version {version}") + if len(candidates) == limit: + self.log(f"{agreement.kind}: batch cap of {limit} reached, more remain for the next run") +if gate_changed: + bump_cache_version("auth") +``` + +Notes that are not incidental: + +- **There is no `owns_lock()` guard and there must not be one.** `main.py:289-293` calls + `service_manager.supervise()` only in the worker that won `acquire_service_lock()`, so `run_once` is + physically unreachable on any other worker. `ModerationService` has no such guard either. Adding one would + be cargo cult; the routers that call `owns_lock()` do so because a *request* can land on any worker. +- The batch-cap log line is required by axiom A10. A silently truncated sweep reads as "everyone is converged" + when they are not. +- `converge_user` returning `False` is normal, not an error: it means the account was claimed by a real + acceptance or a withdrawal between the `SELECT` and the `UPDATE`. The count difference in the log is the + operator's visibility into that. +- `sample_names(candidates)` caps the dry-run listing at 20 usernames and appends the remaining count. +- No exception is swallowed here. `BaseService._execute_run` already wraps `run_once`, logs + `Error in run_once: {e}` to the service's buffer, and keeps the supervisor loop alive + (`services/base.py:307-313`). + +### 9.3 `collect_metrics` + +The detail page renders `{"stats": [...], "table": {"columns": [...], "rows": [...]}}`, the shape +`ContainerService` and `BotsService` already return. + +| Stat | Source | +|---|---| +| `Dry run` | the config field, as `1`/`0` | +| `Agreements enabled` | count of registry entries whose field reads true | +| `