770 lines
46 KiB
Markdown
770 lines
46 KiB
Markdown
|
|
# DevPlace: acceptance convergence implementation design
|
||
|
|
|
||
|
|
Author: retoor <retoor@molodetz.nl>
|
||
|
|
|
||
|
|
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 = <user>
|
||
|
|
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 <live account clauses>
|
||
|
|
AND <not_withdrawn>
|
||
|
|
AND <not_satisfied>
|
||
|
|
ORDER BY u.id
|
||
|
|
LIMIT :limit
|
||
|
|
```
|
||
|
|
|
||
|
|
`<not_withdrawn>` is the fragment from section 5, correlated on `u.uid`.
|
||
|
|
|
||
|
|
`<not_satisfied>` 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 | `<not_satisfied>` |
|
||
|
|
|---|---|---|
|
||
|
|
| 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 `<not_withdrawn>`, 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 <not_satisfied, the same clause pending used>
|
||
|
|
```
|
||
|
|
|
||
|
|
`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 |
|
||
|
|
| `<label> pending` | `len(pending(agreement, limit))`, suffixed `+` when it equals the limit |
|
||
|
|
| `<label> converged` | an in-memory counter on the service instance, since process start |
|
||
|
|
|
||
|
|
The table has one row per agreement: kind, label, enabled, versioned, pending, converged since start.
|
||
|
|
|
||
|
|
The pending counts reuse the same bounded `pending()` the run uses, deliberately: an unbounded `COUNT(*)` over
|
||
|
|
`users` with a correlated subquery per row is the kind of aggregate the framework tells you to keep out of the
|
||
|
|
tick. Reporting a saturated count as `200+` is honest and costs one already written query.
|
||
|
|
`METRICS_SECONDS = 60` keeps even that off the one-second tick, and `_safe_metrics` already swallows and logs
|
||
|
|
any exception so a metrics bug can never take the service down (`services/base.py:227-233`).
|
||
|
|
|
||
|
|
### 9.4 Registration
|
||
|
|
|
||
|
|
One line in the existing `lifespan` register block in `main.py`, and nothing else anywhere:
|
||
|
|
|
||
|
|
```python
|
||
|
|
service_manager.register(AcceptanceService())
|
||
|
|
```
|
||
|
|
|
||
|
|
Registered in every worker so `describe_all()` works on any of them, supervised only on the lock owner, like
|
||
|
|
every other service. This is the single import permitted by clause I5.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. Backwards compatibility
|
||
|
|
|
||
|
|
"Works against a database that predates this feature" is a hard requirement. It has two halves: columns that
|
||
|
|
exist but hold `NULL`, and columns that do not exist at all.
|
||
|
|
|
||
|
|
| Situation | Rule |
|
||
|
|
|---|---|
|
||
|
|
| `users.terms_version` is `NULL` on every account created before the trust-and-safety commit; `init_db` ensures the column but deliberately never backfills it. | `COALESCE(u.terms_version, '') != :version`. A bare `!=` against `NULL` yields `NULL`, not true, and would silently converge nobody. |
|
||
|
|
| `users.is_active` **is not ensured by `init_db` at all** and only exists once a suspension or a deletion has written it. | Build the clause with `has_column`, per section 7.1. A raw reference to a missing column is an `OperationalError`, not a `NULL`. |
|
||
|
|
| `users.is_active` exists but is `NULL` on rows written before the first suspension. | `COALESCE(u.is_active, 1) != 0`, matching `is_account_active` exactly. |
|
||
|
|
| `users.deletion_requested_at` is `NULL` on every row that predates account deletion. | `COALESCE(u.deletion_requested_at, '') = ''`, the guard `claim_deletion` uses. |
|
||
|
|
| `user_consents` does not exist on a database that has never run the trust-and-safety `init_db`. | `if CONSENTS_TABLE not in db.tables: return []`. Fail closed. |
|
||
|
|
| A consent row written before a version was recorded carries `version = NULL`. | `COALESCE(c.version, '') != :version`, which correctly treats it as stale and re-grants at the current version. |
|
||
|
|
| `site_settings` has no `terms_version` row, or has one holding `""`. | `get_setting(key, "1") or "1"`. |
|
||
|
|
|
||
|
|
No migration, no backfill, no `init_db` change, no `operational_defaults` entry and no schema change of any
|
||
|
|
kind. The feature is additive to an untouched database, which is the same property that lets `/admin/trash`
|
||
|
|
restore an event written before the trash existed.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 11. Correctness verification
|
||
|
|
|
||
|
|
This feature is a read-then-write mutation reachable from more than one request path, so the four-layer
|
||
|
|
procedure in the root `CLAUDE.md` applies in full, in addition to the persisted tests in section 12. All four
|
||
|
|
are disposable scripts run against a temp database via `DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR`, not pytest
|
||
|
|
files.
|
||
|
|
|
||
|
|
**The invariant, stated once:** *for any `(user, kind)` pair, a row written by the service never follows a
|
||
|
|
`withdrawn` row.* Everything below tests that one sentence.
|
||
|
|
|
||
|
|
### Layer 1: properties over the full input domain
|
||
|
|
|
||
|
|
`pending` and `satisfied_clause` are pure with respect to a given database state, so enumerate that state
|
||
|
|
rather than sampling it. Build the full matrix: ledger state in `{absent, granted, withdrawn}` x row version
|
||
|
|
in `{"", "1", "2"}` x gate column in `{NULL, "", "1", "2"}` x current version in `{"1", "2"}`, for each of the
|
||
|
|
five agreements. That is a few hundred cases and every one has a hand-derivable answer.
|
||
|
|
|
||
|
|
Assert:
|
||
|
|
|
||
|
|
- `pending` returns the account if and only if it is neither satisfied nor declined;
|
||
|
|
- **decline monotonicity**: once the latest row is `withdrawn`, no subsequent version bump, in either
|
||
|
|
direction, makes the account pending again;
|
||
|
|
- **version monotonicity**: for a non-declined account, changing the current version to a value different from
|
||
|
|
the recorded one always makes it pending, and back again always makes it satisfied;
|
||
|
|
- **gate agreement**: for every state in the matrix, `pending`'s verdict for `terms` equals
|
||
|
|
`routers.auth.terms.needs_acceptance` on the same row, and for the other four it equals
|
||
|
|
`database.consent_granted` combined with the version comparison. This is the clause that proves "never a
|
||
|
|
proxy" rather than asserting it.
|
||
|
|
|
||
|
|
Run the matrix twice: once on a table with an `is_active` column and once on a table without one, to cover
|
||
|
|
both branches of section 7.1.
|
||
|
|
|
||
|
|
### Layer 2: stateful fuzzing
|
||
|
|
|
||
|
|
Fifty accounts, a few thousand randomized actions drawn from: bump `terms_version`, bump `privacy_version`,
|
||
|
|
human grant, human withdraw, run the service, toggle an agreement field, toggle the master switch, toggle dry
|
||
|
|
run. Re-check after **every** action:
|
||
|
|
|
||
|
|
- no `granted` row written by the service follows a `withdrawn` row for the same pair;
|
||
|
|
- with the master switch off, with the agreement off, or with dry run on, the ledger and the `users` row are
|
||
|
|
byte-identical before and after the run;
|
||
|
|
- every row the service wrote has exactly the column set `set_consent` produces, with the same empty-string
|
||
|
|
convention on `granted_at`/`withdrawn_at`;
|
||
|
|
- the number of `terms.accept` and `consent.grant` audit rows with `actor_kind = "service"` equals the number
|
||
|
|
of rows the service actually wrote, never more.
|
||
|
|
|
||
|
|
Catch the expected exception types and continue; the goal is invariant violations and unexpected exceptions.
|
||
|
|
|
||
|
|
### Layer 3: concurrency, with real processes
|
||
|
|
|
||
|
|
Sixteen real OS processes, never threads: `dataset` gives each thread its own pooled connection and enough
|
||
|
|
threads produce `database is locked` noise that is a harness artifact, not a finding.
|
||
|
|
|
||
|
|
Two scenarios, each on a genuinely fresh account whose columns are left exactly as a real signup leaves them.
|
||
|
|
**Do not pre-seed or zero `terms_version`, `is_active` or `deletion_requested_at`.** Pre-zeroing a column that
|
||
|
|
production leaves `NULL` is precisely the mistake that made an earlier race-safety pass in this codebase pass
|
||
|
|
while hiding the bug it was written to catch.
|
||
|
|
|
||
|
|
1. **Convergence storm.** Sixteen processes call `converge_user` for the same account and agreement. Exactly
|
||
|
|
one wins. Exactly one ledger row is written. Exactly one audit row is recorded.
|
||
|
|
2. **Grant against withdrawal.** Eight processes converge, eight call `set_consent(..., granted=False)`. Scan
|
||
|
|
the resulting ledger ordered by `created_at, id` and assert no service-written `granted` row follows a
|
||
|
|
`withdrawn` row. Since only the service grants in this scenario, the invariant is exact.
|
||
|
|
|
||
|
|
### Layer 4: static analysis
|
||
|
|
|
||
|
|
`pyflakes` and `ruff check` on every touched file. `py_compile` and a clean
|
||
|
|
`python -c "from devplacepy.main import app"` prove syntax and import order only; neither catches a missing
|
||
|
|
import inside a function body, and this design uses lazy imports in three functions.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 12. Persisted tests
|
||
|
|
|
||
|
|
Tier is decided by fixtures, and the paths mirror the source module path. `tests/unit/services/moderation/`
|
||
|
|
and `tests/api/admin/services/` already exist and set the pattern.
|
||
|
|
|
||
|
|
| File | Tier | Covers |
|
||
|
|
|---|---|---|
|
||
|
|
| `tests/unit/services/acceptance/__init__.py` | - | package |
|
||
|
|
| `tests/unit/services/acceptance/agreements.py` | unit | registry completeness against `CONSENT_KINDS` (the enforcement test), unique setting keys, every agreement has a config field, labels resolve |
|
||
|
|
| `tests/unit/services/acceptance/pending.py` | unit | `local_db`: the satisfied and declined matrix per shape, `NULL` legacy rows, the missing-`is_active` branch, missing-table fail-closed, batch bound, stable ordering |
|
||
|
|
| `tests/unit/services/acceptance/grant.py` | unit | `local_db`: a won claim writes the gate column and the ledger row; a lost claim writes nothing; a withdrawn ledger is never converged; the written row matches `set_consent` field for field (clause I4); the audit row carries `actor_kind="service"` and an existing event key |
|
||
|
|
| `tests/unit/services/acceptance/isolation.py` | unit | greps the source tree and asserts `devplacepy/main.py` is the only importer (clause I5), and that no template, schema, router or Devii catalog file mentions the package |
|
||
|
|
| `tests/api/admin/services/acceptance.py` | api | the service is listed at `/admin/services`, reports `stopped` on a fresh instance, its detail page renders the per-agreement fields, saving the config validates and persists, and a non-admin is redirected |
|
||
|
|
|
||
|
|
`tests/unit/services/acceptance/isolation.py` is the test that makes the invisibility contract survive future
|
||
|
|
edits. Without it, clause I5 is a promise; with it, it is a build failure.
|
||
|
|
|
||
|
|
**Do not write a test that enables the service and asserts convergence in the shared suite.** The suite runs
|
||
|
|
serially against one seeded database, and a service that grants `terms` to every account would poison the
|
||
|
|
moderation tests that assert the gate refuses. Convergence behaviour is covered by the unit tests calling
|
||
|
|
`converge_user` directly against `local_db`, which is where it belongs. The api test asserts the admin
|
||
|
|
surface only, and must leave `service_acceptance_enabled` untouched.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 13. Operator procedures
|
||
|
|
|
||
|
|
To be written into the admin-only docs page, section 14.
|
||
|
|
|
||
|
|
**Enable it.**
|
||
|
|
|
||
|
|
1. `/admin/services/acceptance`, Configuration tab: switch on only the agreements the test run needs.
|
||
|
|
2. Leave dry run on. Start the service. Wait one interval, or use Run now. Read the Logs tab and confirm the
|
||
|
|
accounts listed are the ones expected.
|
||
|
|
3. Switch dry run off. The next run converges them.
|
||
|
|
|
||
|
|
**Test the refusal path for a consent.** From the tester's own profile, privacy tab, withdraw the consent. It
|
||
|
|
is never granted again while that withdrawal stands, regardless of how many intervals pass.
|
||
|
|
|
||
|
|
**Test the terms gate.** The tester withdraws their own `terms` consent, then an administrator bumps
|
||
|
|
`terms_version` at `/admin/settings`. Every other account converges; the tester stays gated.
|
||
|
|
|
||
|
|
**Test a partial state.** Enable `terms` and leave `privacy` off. Accounts pass the write gate but still show
|
||
|
|
the privacy policy as unaccepted on their privacy tab, which is the edge case the per-agreement switches
|
||
|
|
exist for.
|
||
|
|
|
||
|
|
**Undo a decline.** Grant the consent from the profile page. The ledger's latest row is `granted` again and
|
||
|
|
the account leaves the pending set; nothing to reset and no operator action on the service.
|
||
|
|
|
||
|
|
**Turn it off.** Stop the service, or switch off the individual agreement. Nothing already written is
|
||
|
|
reverted, and nothing should be: the accounts are in exactly the state a real population would have reached.
|
||
|
|
|
||
|
|
**Never enable it on production.** The description on the service page says so, and this is the only control
|
||
|
|
that matters. There is no environment detection anywhere in the design, deliberately: an environment flag
|
||
|
|
would be exactly the knowledge the application is not allowed to have.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 14. Documentation
|
||
|
|
|
||
|
|
| File | Change |
|
||
|
|
|---|---|
|
||
|
|
| `devplacepy/services/acceptance/CLAUDE.md` | New. The nested subsystem doc: why the subsystem exists, the invisibility contract, the decline register and its ordering rule, the module map, the exclusions from section 4.1, the `has_column` rule from section 7.1, the single cache bump, the rules for extending it. |
|
||
|
|
| `CLAUDE.md` (root) | One row in the Subsystem map table. No architectural rule is introduced, so nothing else changes. |
|
||
|
|
| `devplacepy/services/CLAUDE.md` | One short section, sized and placed like the "Moderation housekeeping" section it sits beside. |
|
||
|
|
| `events.md` | Add `services/acceptance/grant.py` to the "Recorded in" column of `terms.accept` (line 72) and `consent.grant` (line 69). No new key, no category count change, no change to the header's key count. |
|
||
|
|
| `README.md` | One subsection under the background services material, stating that it is admin-only, off by default, and what it is for. |
|
||
|
|
| `devplacepy/templates/docs/moderation-operations.html` | One section carrying the procedures from section 13. The page is already `"admin": True` in `DOCS_PAGES` (`routers/docs/pages.py:195-199`), so it is invisible to members with no gating work. |
|
||
|
|
|
||
|
|
No `docs_api.py` entry and no Devii action, because there is no endpoint and no user-facing capability. This
|
||
|
|
is the one deliberate exemption from the four-faces rule in the root `CLAUDE.md`, and it is the exemption
|
||
|
|
every pure `BaseService` already takes: `NotificationRelayService`, `PresenceRelayService`,
|
||
|
|
`LiveViewRelayService` and `AuditService` have no routes, no schemas and no agent tools either. Their surface
|
||
|
|
is the generic services admin, which this service inherits without writing a line of template or JavaScript.
|
||
|
|
Exposing a Devii tool for it would additionally hand the assistant, which runs inside the application, direct
|
||
|
|
knowledge of the feature, breaking clause I5 for no benefit.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 15. Definition of done
|
||
|
|
|
||
|
|
In order. A failure at any step blocks the next.
|
||
|
|
|
||
|
|
1. `python -c "from devplacepy.main import app"` imports clean.
|
||
|
|
2. `pyflakes` and `ruff check` clean on every touched file.
|
||
|
|
3. No em dash, neither the character nor the HTML entity, in any touched file.
|
||
|
|
4. The four verification layers of section 11 run and pass, including the sixteen-process race and both
|
||
|
|
branches of the missing-column matrix.
|
||
|
|
5. `make test` passes in full: unit, api and e2e, every test, no tier skipped and no subset substituted.
|
||
|
|
6. A manual check on a dev instance: with everything default, `/admin/services/acceptance` shows `stopped` and
|
||
|
|
the database is untouched after ten minutes. With the service started, dry run on and `terms` enabled, the
|
||
|
|
log lists the pending accounts and the database is still untouched. With dry run off, the accounts converge
|
||
|
|
within one interval, the audit log shows `terms.accept` rows with actor kind `service`, and an account
|
||
|
|
whose `terms` consent was withdrawn is still gated.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 16. Non-goals
|
||
|
|
|
||
|
|
- No environment variable, no build flag, no `ACCEPTANCE_MODE` of any kind. The service is the entire
|
||
|
|
mechanism.
|
||
|
|
- No fabrication of declared facts: age band and date of birth are never written.
|
||
|
|
- No new consent kind, no new agreement outside `CONSENT_KINDS`, and no second decline register.
|
||
|
|
- No convergence of guests, of deleted or anonymised accounts, or of deactivated accounts.
|
||
|
|
- No reversal path. The service grants; only a human withdraws.
|
||
|
|
- No route, no JSON schema, no template, no Devii tool, no API documentation entry.
|