Files
devplacepy/devplacepy/services/acceptance/service.py
T
retoorandClaude Opus 5 7e37122f9f Converge every account onto every policy agreement it has not declined
An instance kept production-identical for extended manual testing is
otherwise taxed forever by its 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. AcceptanceService grants each
agreement to each account that has not declined it, so the instance stays
production byte for byte while nobody clicks the same dialog again. It is
opt-in, dry run by default, and one switch per agreement type.

The application is not allowed to know it exists. One registration line
in main.py is the only import anywhere, there is no route, schema,
template, Devii tool or environment flag, and a unit test greps the tree
and fails the suite if a second importer appears. The decline register
needs no storage: the ledger is append-only in effect, the service only
ever grants, so any withdrawn row was written by a human and that pair is
never touched again. No provenance column, nothing to observe.

Satisfaction is the gate's own expression, never a proxy, which is why
the ordering is created_at then id exactly as consent_state selects, and
why the live-account clauses are built with has_column: init_db ensures
terms_version and deletion_requested_at but not is_active, so a hardcoded
reference raises no such column on an instance where nobody was ever
suspended. Every write is one conditional statement decided on the real
rowcount, proven with sixteen processes racing one account to exactly one
ledger row and one audit row. The two existing audit keys carry it, with
actor kind service, because a service that silently mutated consent state
would be the worst possible exception to the append-only rule.

lensfl.md is the source brief accept.md records the design against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:17:47 +02:00

169 lines
5.9 KiB
Python

# retoor <retoor@molodetz.nl>
import logging
from devplacepy.database import CONSENT_KINDS, bump_cache_version
from devplacepy.services.acceptance.agreements import (
AGREEMENTS,
label_for,
setting_key,
)
from devplacepy.services.acceptance.grant import converge_user
from devplacepy.services.acceptance.pending import current_version, pending
from devplacepy.services.base import BaseService, ConfigField
logger = logging.getLogger(__name__)
DRY_RUN_KEY = "acceptance_dry_run"
BATCH_SIZE_KEY = "acceptance_batch_size"
DEFAULT_BATCH_SIZE = 200
MAX_BATCH_SIZE = 5000
SAMPLE_NAMES = 20
def sample_names(candidates: list[dict]) -> str:
names = [row.get("username") or row.get("uid") or "" for row in candidates]
shown = ", ".join(names[:SAMPLE_NAMES])
remaining = len(names) - SAMPLE_NAMES
if remaining > 0:
return f"{shown} and {remaining} more"
return shown
class AcceptanceService(BaseService):
title = "Acceptance convergence"
description = (
"Grants every policy agreement to every account that has not declined it, "
"so a production-identical test instance never interrupts manual testing "
"with an acceptance dialog. Off by default and never appropriate on a real "
"production host."
)
details = (
"An account that withdrew a consent is never granted it again, with no "
"further action: the latest row in the consent ledger is the decline "
"register. The service only ever grants, so any withdrawal in the ledger "
"was written by a human. Withdraw a consent from the profile privacy tab "
"to keep an account permanently outside the convergence."
)
default_enabled = False
min_interval = 60
METRICS_SECONDS = 60
config_fields = [
ConfigField(
DRY_RUN_KEY,
"Dry run",
type="bool",
default=True,
help=(
"Log which accounts would be converged and write nothing. Switch "
"off only after the log lists what you expect."
),
group="Safety",
),
ConfigField(
BATCH_SIZE_KEY,
"Accounts per agreement per run",
type="int",
default=DEFAULT_BATCH_SIZE,
minimum=1,
maximum=MAX_BATCH_SIZE,
help="Upper bound on one sweep. The remainder converges on the next run.",
group="Safety",
),
*[
ConfigField(
setting_key(agreement.kind),
label_for(agreement.kind),
type="bool",
default=False,
help=f"Grant the {agreement.kind} agreement to every account that has not declined it.",
group="Agreements",
)
for agreement in AGREEMENTS
],
]
def __init__(self) -> None:
super().__init__("acceptance", interval_seconds=300)
self._converged = {agreement.kind: 0 for agreement in AGREEMENTS}
async def run_once(self) -> None:
config = self.get_config()
dry = config[DRY_RUN_KEY]
limit = config[BATCH_SIZE_KEY]
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 "
f"converged 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._converged[agreement.kind] += granted
self.log(
f"{agreement.kind}: converged {granted} of {len(candidates)} pending "
f"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")
def collect_metrics(self) -> dict:
config = self.get_config()
limit = config[BATCH_SIZE_KEY]
enabled = [
agreement
for agreement in AGREEMENTS
if config[setting_key(agreement.kind)]
]
stats = [
{"label": "Dry run", "value": 1 if config[DRY_RUN_KEY] else 0},
{"label": "Agreements enabled", "value": len(enabled)},
]
rows = []
for agreement in AGREEMENTS:
is_enabled = config[setting_key(agreement.kind)]
count = len(pending(agreement, limit)) if is_enabled else 0
display = f"{count}+" if is_enabled and count == limit else str(count)
if is_enabled:
stats.append(
{"label": f"{agreement.kind} pending", "value": display}
)
rows.append(
[
agreement.kind,
CONSENT_KINDS.get(agreement.kind, agreement.kind)[:48],
1 if is_enabled else 0,
agreement.version_setting or "",
display if is_enabled else "",
self._converged[agreement.kind],
]
)
table = {
"columns": [
"Agreement",
"Policy",
"Enabled",
"Version setting",
"Pending",
"Converged",
],
"rows": rows,
}
return {"stats": stats, "table": table}