From 3a8da1e2929eae1435d706d2d3c21e5d70a49e58 Mon Sep 17 00:00:00 2001 From: Parshkov Date: Sun, 6 Sep 2026 22:36:08 -0700 Subject: [PATCH] Empty the corpus once, and keep everyone signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production held eight thoughts, all written here to prove a path worked, plus the alerts they raised against each other and an introduction whose own thought had already been deleted. The first person to arrive cannot tell a test from a stranger, and would have been introduced to one. `RESONANCE_PURGE_CORPUS=report|1` removes every thought, every standing-search alert, every introduction, every conversation and every shared topic — and leaves accounts, sign-ins and OAuth client registrations alone. That distinction is the whole point: `reset` also wipes both, so it would sign everyone out and make every connected MCP client re-authorize to achieve the same empty corpus. It takes no exceptions, and refuses RESONANCE_PURGE_KEEP rather than ignoring it, because removing named thoughts is already RESONANCE_PURGE_SESSIONS. Two defects found on the way, both of which left rows behind: `RESONANCE_PURGE_SESSIONS` never retracted standing-search alerts, because it calls the corpus service directly and so bypasses the product's retract. Now it retracts both sides — an alert is a pointer to a pair of thoughts, and `retract_for_session` only ever reached the owner's half, leaving the counterpart's copy naming a deleted thought until whenever they next looked. `reset()` and `import_payload()` cleared intros, channels and messages and forgot all eight workspace tables, so a "reset" store still held every shared topic. Both now name one list, CONNECTION_TABLES. Co-Authored-By: Claude Opus 5 --- ROADMAP.md | 13 +- ops/DEPLOY.md | 2 + src/persistence/postgres_store.py | 71 ++++++++++- src/persistence/repository.py | 2 + src/product/server.py | 137 +++++++++++++++++++++ src/product/web_server.py | 2 + tests/test_purge_corpus.py | 198 ++++++++++++++++++++++++++++++ 7 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 tests/test_purge_corpus.py diff --git a/ROADMAP.md b/ROADMAP.md index 47b44c1..206cbfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,15 +2,20 @@ ## The thing that decides everything -**Seven shared thoughts exist in the world.** That is the entire live corpus. +**No shared thoughts exist in the world.** That is the entire live corpus. + +It briefly held eight, all of them written here to prove a path worked, and on +2026-09-06 they were removed (`RESONANCE_PURGE_CORPUS`, `ops/DEPLOY.md`) — not +because they were in the way, but because the first person to arrive cannot tell +a test from a stranger, and would have been introduced to one. Everything below is secondary to that, and it is worth saying plainly because the project has spent most of its effort on the other side. The engine now has eighteen benchmark families, five verdicts, thirteen thresholds, four policy -versions and seven ADRs. The corpus has seven thoughts. A matcher with nobody +versions and seven ADRs. The corpus has nothing in it. A matcher with nobody to match is not a product, however good the matching is — and the matching is -now good enough: on 2026-09-06 it found a genuine cross-domain twin between two -people who had never met. +now good enough: on 2026-09-06, before the corpus was emptied, it found a +genuine cross-domain twin between two people who had never met. There is an irony worth naming. The thought that produced that match was about a registry of employer conduct, and its author had already reasoned out the diff --git a/ops/DEPLOY.md b/ops/DEPLOY.md index 206cc57..a4c523b 100644 --- a/ops/DEPLOY.md +++ b/ops/DEPLOY.md @@ -14,6 +14,8 @@ dependency is the PostgreSQL driver (`psycopg[binary]`), installed by the | browser origin allowlist | `--origin https://your.host` (repeatable) | must be the **exact** `https://` origin browsers will use; this is the CSRF/Origin check. Add a second `--origin` for a platform default host alongside a custom domain. | | bind address / port | `--host 0.0.0.0 --port $PORT` | the image reads `PORT` from the platform | | retire unsigned accounts | `RESONANCE_PURGE_UNSIGNED=report` counts and prints; `=1` carries it out. Tombstones every session whose owning account has no verified sign-in behind it, and revokes those accounts. `RESONANCE_PURGE_KEEP=[,…]` spares named sessions or accounts. A signed-in account is never touched, and a second run finds nothing left to do. Run `report` first, read the counts in the deploy log, then `=1`, then unset. | one-shot operator action | +| empty the corpus | `RESONANCE_PURGE_CORPUS=report` counts and prints; `=1` carries it out. Removes every thought, every standing-search alert, every introduction, every conversation and every shared topic — and leaves accounts, sign-ins and OAuth client registrations alone, so nobody signs in again and no connected MCP client re-authorizes. That is the whole reason it exists rather than `python3 -m src.persistence --db reset`, which also wipes both. It takes no exceptions: `RESONANCE_PURGE_KEEP` is **refused**, not ignored — removing named thoughts is `RESONANCE_PURGE_SESSIONS`. Run `report` first, read the counts in the deploy log, then `=1`, then unset. Idempotent. | one-shot operator action | +| remove named thoughts | `RESONANCE_PURGE_SESSIONS=[,…]` tombstones exactly the sessions named and nothing else, and retracts the standing-search alerts on **both** sides of each — the owner's and the one recorded for the person on the other end. Every id is reported with what happened to it, including ids that do not exist, so a run that did less than intended is visible in the log. Idempotent. Unset it after the deploy. | one-shot operator action | | label encoder | `RESONANCE_EMBEDDER=` holding `tokenizer.json` and `onnx/model_quantized.onnx` (multilingual-e5-small, exported to ONNX; ~135 MB) plus the `onnxruntime` and `tokenizers` packages | **recommended.** Without it the semantic layer is the hand-written English lexicon, which is blind to most real vocabulary and to every other language: the same trip described twice in different words came back "not a resonance". With it, each label is embedded locally on the CPU (about 6 ms a pair, cached), and the cosine adds relatedness the lexicon could not see; structure, contradiction and the verdict are unchanged. The server refuses to start if the variable names a directory it cannot load, and `/api/product/health` reports `engine.label_encoder`. Build the image with `--build-arg RESONANCE_EMBEDDER_MODEL=Xenova/multilingual-e5-small` to bake the model in. | | sign-in providers | `RESONANCE_AUTH_GOOGLE_CLIENT_ID` / `RESONANCE_AUTH_GOOGLE_CLIENT_SECRET`, and/or `RESONANCE_AUTH_GITHUB_CLIENT_ID` / `RESONANCE_AUTH_GITHUB_CLIENT_SECRET` | **required for a real deployment.** Setting either pair turns on sign-in, and sign-in then becomes the *only* way an account is created: `POST /api/product/guest` answers `403 sign_in_required`, and the OAuth consent page offers no anonymous option. With neither pair set the pseudonymous guest path stays on — that is the local-development and test configuration, not a production one. Callback URL to register with the provider: `https:///auth/callback/google` (and `/auth/callback/github`). Scopes requested are only `openid email profile` / `read:user user:email`. | | how mail leaves | This platform blocks outbound SMTP below its Pro plan, and its own documentation says so: "SMTP is only available on the Pro plan and above... Free, Trial, and Hobby plans must use transactional email services with HTTPS APIs", with Resend named as the recommended one. Measured here before that was found: ports 587, 465 and 25 all time out on IPv4, and IPv6 is off by default so every AAAA answers "network is unreachable". So set `RESONANCE_MAIL_API_KEY` (and `RESONANCE_MAIL_FROM`) and mail goes out over 443, the same door the site is served from. `RESONANCE_MAIL_API_URL` defaults to Resend's endpoint; Postmark and Mailgun differ only in field names. The `RESONANCE_SMTP_*` path still works where SMTP is allowed, and an API key wins over it wherever both are set. | | diff --git a/src/persistence/postgres_store.py b/src/persistence/postgres_store.py index a4b9150..368e756 100644 --- a/src/persistence/postgres_store.py +++ b/src/persistence/postgres_store.py @@ -68,6 +68,25 @@ def _connect(dsn: str): _SAFE_SCHEMA = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,54}") +# What two people made together, in the order a delete may walk them: rows that +# reference a workspace before the workspace, and messages before the channel +# that holds them. Distinct from the corpus (`sessions`) and from the account +# (`users`, `oauth_grants`), which is why `reset()` names all three and +# `delete_connections()` names only this one. +CONNECTION_TABLES = ( + "workspace_contributions", + "workspace_activity", + "workspace_links", + "workspace_artifacts", + "workspace_tasks", + "workspace_notes", + "workspace_members", + "workspaces", + "messages", + "channels", + "intros", +) + class PostgresRepository: backend_name = "postgres" @@ -218,9 +237,7 @@ def reset(self) -> None: try: for table in ( "oauth_grants", - "messages", - "channels", - "intros", + *CONNECTION_TABLES, "idempotency_keys", "audit_events", "sessions", @@ -233,6 +250,32 @@ def reset(self) -> None: self._conn.rollback() raise + def delete_connections(self) -> dict[str, int]: + """Remove every introduction, channel, message and shared topic. + + Accounts, sessions, sign-ins and OAuth client registrations are left + alone, so this empties what people made together without making anyone + sign in again or any connected client re-authorize. + + No corpus generation bump: none of these tables is discoverable corpus + content, so the discovery index does not go stale when they change + (the same reason workspace writes never bump it -- see 0004). + """ + with self._lock: + try: + removed: dict[str, int] = {} + for table in CONNECTION_TABLES: + row = self._fetchone_map(f"SELECT COUNT(*) AS n FROM {table}") + count = int(row["n"]) if row else 0 + if count: + self._execute(f"DELETE FROM {table}") + removed[table] = count + self._conn.commit() + return removed + except Exception: + self._conn.rollback() + raise + def close(self) -> None: with self._lock: self._conn.close() @@ -389,6 +432,24 @@ def list_grants_for_user(self, kind: str, user_id: str) -> Sequence[Mapping[str, self._conn.commit() return [loads(row["record_json"]) for row in rows] + def delete_grants_of_kind(self, kind: str) -> int: + """Every record of one kind, whoever it belongs to. + + `delete_grants_for_user` cannot stand in for this: it walks the accounts + it is given, and a record whose owning account has since been revoked or + removed belongs to none of them, so it would be left behind and counted + by whoever looks next. + """ + with self._lock: + try: + cur = self._execute("DELETE FROM oauth_grants WHERE kind = ?", (kind,)) + removed = int(cur.rowcount or 0) + self._conn.commit() + return removed + except Exception: + self._conn.rollback() + raise + def delete_grants_for_user(self, kind: str, user_id: str) -> int: with self._lock: try: @@ -594,9 +655,7 @@ def import_payload(self, payload: Mapping[str, Any]) -> None: with self._lock: try: for table in ( - "messages", - "channels", - "intros", + *CONNECTION_TABLES, "idempotency_keys", "audit_events", "sessions", diff --git a/src/persistence/repository.py b/src/persistence/repository.py index 8f631aa..738fb43 100644 --- a/src/persistence/repository.py +++ b/src/persistence/repository.py @@ -24,6 +24,7 @@ def migrate(self) -> tuple[str, ...]: ... def health(self) -> dict[str, Any]: ... def get_corpus_generation(self) -> int: ... def reset(self) -> None: ... + def delete_connections(self) -> dict[str, int]: ... def close(self) -> None: ... def put_user( @@ -62,6 +63,7 @@ def put_grant(self, kind: str, key: str, record: Mapping[str, Any], *, def get_grant(self, kind: str, key: str) -> Mapping[str, Any] | None: ... def pop_grant(self, kind: str, key: str) -> Mapping[str, Any] | None: ... def list_grants_for_user(self, kind: str, user_id: str) -> Sequence[Mapping[str, Any]]: ... + def delete_grants_of_kind(self, kind: str) -> int: ... def delete_grants_for_user(self, kind: str, user_id: str) -> int: ... def export_payload(self) -> dict[str, Any]: ... diff --git a/src/product/server.py b/src/product/server.py index ef47c0c..1cec1d6 100644 --- a/src/product/server.py +++ b/src/product/server.py @@ -62,6 +62,7 @@ from src.product.notify import (Notifier, NoTransport, account_in_token, self_test) from src.product.service import LiveProductService, ProductError, StaleResultError +from src.product.standing import ALERT_KIND from src.product.mcp_bridge import ( BridgeError, INVALID_REQUEST, @@ -205,6 +206,40 @@ def startup_purge_demo(runtime: "ProductRuntime", environ: Mapping[str, str] | N return result +def _retract_alerts_for(runtime: "ProductRuntime", session_ids: set[str]) -> int: + """Drop every standing-search alert that points at one of these thoughts. + + An alert is a pointer to a pair of thoughts, so when either end is deleted + the alert is no longer about anything. `StandingSearch.retract_for_session` + reaches only the owner's own side -- the alert recorded for the *other* + person still names the deleted thought, and survives until whenever they + next look, when the liveness re-check finally drops it. That is right for a + person revoking one thought and wrong for an operator emptying a store: the + rows stay, and the next operator counts them. + + Never fails the boot: an alert left behind is filtered on read anyway. + """ + repo = getattr(runtime.live, "repo", None) + if repo is None or not hasattr(repo, "list_grants_for_user") or not session_ids: + return 0 + removed = 0 + try: + for user in repo.list_users(): + user_id = str(getattr(user, "user_id", "") or "") + if not user_id: + continue + for record in list(repo.list_grants_for_user(ALERT_KIND, user_id)): + mine = str(record.get("my_session_id") or "") + theirs = str(record.get("their_session_id") or "") + if mine not in session_ids and theirs not in session_ids: + continue + repo.pop_grant(ALERT_KIND, str(record.get("alert_key", ""))) + removed += 1 + except Exception as exc: # noqa: BLE001 - report, never abort the boot + print(f"standing search: retract failed ({exc.__class__.__name__}: {exc})") + return removed + + def startup_purge_sessions(runtime: "ProductRuntime", environ: Mapping[str, str] | None = None) -> dict[str, Any] | None: """One-shot operator action: ``RESONANCE_PURGE_SESSIONS=[,…]`` @@ -248,14 +283,17 @@ def startup_purge_sessions(runtime: "ProductRuntime", runtime.live.delete_session(session_id, rebuild=False) outcome[session_id] = "deleted" deleted += 1 + alerts = _retract_alerts_for(runtime, {k for k, v in outcome.items() if v == "deleted"}) if deleted: runtime.live.rebuild_index() result = {"requested": len(wanted), "deleted": deleted, "already_deleted": sum(1 for v in outcome.values() if v == "already_deleted"), "missing": sum(1 for v in outcome.values() if v == "missing"), + "alerts_retracted": alerts, "outcome": outcome} print(f"purge-sessions: requested={result['requested']} deleted={result['deleted']} " f"already_deleted={result['already_deleted']} missing={result['missing']} " + f"alerts_retracted={alerts} " f"({', '.join(f'{k}={v}' for k, v in outcome.items())}) " f"(RESONANCE_PURGE_SESSIONS set; unset it after this deploy)") return result @@ -428,6 +466,104 @@ def startup_purge_unsigned(runtime: "ProductRuntime", return result +def startup_purge_corpus(runtime: "ProductRuntime", + environ: Mapping[str, str] | None = None) -> dict[str, Any] | None: + """One-shot operator action: empty the shared corpus, keep the accounts. + + ``RESONANCE_PURGE_CORPUS=report`` counts and prints; ``=1`` carries it out. + + What a deployment accumulates before anyone real arrives is not data, it is + the residue of testing: thoughts written to exercise a path, the alerts + they raised against each other, the introductions accepted to prove + introductions work. Left in place it is indistinguishable, to the first + person who arrives, from a world where other people are thinking -- and the + resonance they are shown is with a test. + + So this removes every thought, every standing-search alert, every + introduction, every conversation and every shared topic. It does not touch + accounts, sign-ins or OAuth client registrations: nobody signs in again and + no connected client re-authorizes, which is the whole reason this exists + rather than `python -m src.persistence ... reset`. + + It takes no exceptions. ``RESONANCE_PURGE_KEEP`` is refused rather than + ignored, because an operator who sets it is expecting something to survive + and would otherwise find out afterwards; removing named thoughts is + ``RESONANCE_PURGE_SESSIONS``, which does exactly that and nothing else. + + Prints counts only -- never a topic, a label, a message or any thought + content. Idempotent: a second run finds nothing left to do. + """ + environ = os.environ if environ is None else environ + mode = (environ.get("RESONANCE_PURGE_CORPUS") or "").strip().lower() + if mode not in {"1", "true", "yes", "report", "dry-run"}: + return None + if (environ.get("RESONANCE_PURGE_KEEP") or "").strip(): + print("purge-corpus: REFUSED -- RESONANCE_PURGE_KEEP is set and this action " + "takes no exceptions; use RESONANCE_PURGE_SESSIONS to remove named " + "thoughts, or unset RESONANCE_PURGE_KEEP to empty the corpus") + return {"refused": "RESONANCE_PURGE_KEEP is set"} + dry_run = mode in {"report", "dry-run"} + + live = [row for row in runtime.live.repo.list_sessions() + if getattr(row, "deleted_at", None) is None] + session_ids = {str(getattr(row, "session_id", "") or "") for row in live} + session_ids.discard("") + connections = _count_connections(runtime) + + result: dict[str, Any] = { + "dry_run": dry_run, + "sessions_to_delete": len(session_ids), + "connections_to_delete": connections, + "alerts_retracted": 0, + } + if not dry_run: + for session_id in sorted(session_ids): + runtime.live.delete_session(session_id, rebuild=False) + # Every alert, not only the ones this pass can reach through a live + # account: a corpus with no thoughts in it can hold no true pointer to + # one, and an alert whose owner was revoked belongs to no account to + # walk. + repo = runtime.live.repo + if hasattr(repo, "delete_grants_of_kind"): + result["alerts_retracted"] = int(repo.delete_grants_of_kind(ALERT_KIND)) + else: + result["alerts_retracted"] = _retract_alerts_for(runtime, session_ids) + if hasattr(repo, "delete_connections"): + result["connections_deleted"] = dict(repo.delete_connections()) + if session_ids: + runtime.live.rebuild_index() + print(f"purge-corpus: {'REPORT ONLY, nothing changed' if dry_run else 'applied'} " + f"sessions={result['sessions_to_delete']} " + f"alerts_retracted={result['alerts_retracted']} " + f"connections=" + ",".join(f"{k}:{v}" for k, v in sorted(connections.items()) if v) + + " accounts_and_oauth_untouched=yes " + "(RESONANCE_PURGE_CORPUS set; unset it after this deploy)") + return result + + +def _count_connections(runtime: "ProductRuntime") -> dict[str, int]: + """How much shared state two people made, by table, without removing it. + + Read through the store's own connection so `report` and `applied` count the + same rows; a store that cannot answer reports nothing rather than guessing. + """ + repo = runtime.live.repo + tables = getattr(repo, "connection_tables", None) + if tables is None: + try: + from src.persistence.postgres_store import CONNECTION_TABLES as tables + except ImportError: + return {} + counts: dict[str, int] = {} + for table in tables: + try: + row = repo._fetchone_map(f"SELECT COUNT(*) AS n FROM {table}") + except Exception: # noqa: BLE001 - a table this store does not have + continue + counts[table] = int(row["n"]) if row else 0 + return counts + + def build_runtime( db_path: str = ":ephemeral:", *, @@ -1514,6 +1650,7 @@ def main(argv: list[str] | None = None) -> None: startup_purge_demo(runtime) startup_purge_sessions(runtime) startup_purge_unsigned(runtime) + startup_purge_corpus(runtime) startup_assign_pseudonyms(runtime) # R15C (#136): canonical OAuth for hosted MCP clients on this same origin. # The startup log names the FIRST declared --origin; per-request metadata diff --git a/src/product/web_server.py b/src/product/web_server.py index c6f0bfc..47d4c82 100644 --- a/src/product/web_server.py +++ b/src/product/web_server.py @@ -57,6 +57,7 @@ startup_purge_demo, startup_purge_sessions, startup_assign_pseudonyms, + startup_purge_corpus, startup_purge_unsigned, startup_label_encoder, ) @@ -1276,6 +1277,7 @@ def main(argv: list[str] | None = None) -> None: startup_purge_demo(runtime) startup_purge_sessions(runtime) startup_purge_unsigned(runtime) + startup_purge_corpus(runtime) startup_assign_pseudonyms(runtime) # R15C (#136): canonical OAuth for hosted MCP clients on this same origin. # Per request the issuer is re-derived from the host actually addressed diff --git a/tests/test_purge_corpus.py b/tests/test_purge_corpus.py new file mode 100644 index 0000000..7a4a7af --- /dev/null +++ b/tests/test_purge_corpus.py @@ -0,0 +1,198 @@ +"""Emptying a deployment that was only ever tested in. + +Before anyone real arrives, a live deployment holds the residue of proving it +works: thoughts written to exercise a path, the alerts they raised against each +other, an introduction accepted to show introductions work. To the first person +who arrives that is indistinguishable from a world where other people are +thinking, and the resonance they are shown is with a test. + +These tests pin what the one-shot operator action removes, and — more +importantly — what it must leave standing, because an emptied corpus that also +signs everyone out and disconnects every MCP client is a worse outcome than the +residue. +""" + +from __future__ import annotations + +import unittest + +from src.product import server as product_server +from src.product.server import build_runtime +from src.product.standing import ALERT_KIND + +ORIGINS = frozenset({"http://127.0.0.1"}) + + +def _runtime(): + return build_runtime(":ephemeral:", allowed_origins=ORIGINS) + + +def _an_alert(runtime, user_id: str, mine: str, theirs: str) -> str: + key = f"{user_id}|{mine}|{theirs}" + runtime.live.repo.put_grant(ALERT_KIND, key, { + "alert_key": key, + "user_id": user_id, + "my_session_id": mine, + "their_session_id": theirs, + "mode": "analogical", + "scores_at_detection": {}, + "detected_at": "2026-01-01T00:00:00Z", + "reason": "you_shared", + "seen_at": None, + }, user_id=user_id) + return key + + +def _an_accepted_intro(runtime, a: str, b: str) -> None: + """A durable intro, its channel and one message, written the way the rows + exist after two people were actually introduced.""" + repo = runtime.live.repo + repo._execute( + "INSERT INTO intros(intro_id, from_session_id, to_session_id, from_user_id, " + "to_user_id, state, message, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 'accepted', '', ?, ?)", + ("intro-test", a, b, "person-a", "person-b", "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z")) + repo._execute( + "INSERT INTO channels(channel_id, intro_id, created_at) VALUES (?, ?, ?)", + ("chan-test", "intro-test", "2026-01-01T00:00:00Z")) + repo._execute( + "INSERT INTO messages(message_id, channel_id, author_user_id, body, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ("msg-test", "chan-test", "person-a", "hello", "2026-01-01T00:00:00Z")) + repo._conn.commit() + + +class PurgeCorpusTests(unittest.TestCase): + def test_unset_or_unrecognised_does_nothing_at_all(self): + runtime = _runtime() + try: + before = product_server.corpus_summary(runtime)["sessions_by_kind"] + self.assertIsNone(product_server.startup_purge_corpus(runtime, {})) + self.assertIsNone(product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": ""})) + self.assertIsNone(product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": "maybe"})) + self.assertEqual(product_server.corpus_summary(runtime)["sessions_by_kind"], before) + finally: + runtime.live.repo.close() + + def test_report_counts_without_changing_anything(self): + runtime = _runtime() + try: + ids = [s.session_id for s in runtime.live.repo.list_sessions()] + _an_alert(runtime, "person-gone", ids[0], ids[1]) + _an_accepted_intro(runtime, ids[0], ids[1]) + + result = product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": "report"}) + self.assertTrue(result["dry_run"]) + self.assertEqual(result["sessions_to_delete"], len(ids)) + self.assertEqual(result["connections_to_delete"]["intros"], 1) + self.assertEqual(result["connections_to_delete"]["messages"], 1) + self.assertEqual(result["alerts_retracted"], 0) + + # nothing moved + self.assertEqual(len([s for s in runtime.live.repo.list_sessions() + if s.deleted_at is None]), len(ids)) + self.assertIsNotNone(runtime.live.repo.get_grant( + ALERT_KIND, f"person-gone|{ids[0]}|{ids[1]}")) + finally: + runtime.live.repo.close() + + def test_applied_empties_the_corpus_and_leaves_accounts_and_oauth_standing(self): + runtime = _runtime() + try: + repo = runtime.live.repo + ids = [s.session_id for s in repo.list_sessions()] + users_before = len(repo.list_users()) + self.assertGreater(users_before, 0) + # Deliberately owned by an account that is not in `users`: this is + # what an alert looks like after its owner was revoked, and walking + # live accounts would never reach it. + _an_alert(runtime, "person-gone", ids[0], ids[1]) + _an_alert(runtime, repo.get_session(ids[0]).user_id, ids[0], ids[1]) + _an_accepted_intro(runtime, ids[0], ids[1]) + # An OAuth registration is what a connected MCP client authorized + # with. Wiping it would make every client re-authorize, which is + # the whole reason this is not `reset`. + repo.put_grant("client_registration", "client-test", + {"client_id": "client-test"}, user_id="person-a") + + result = product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": "1"}) + self.assertFalse(result["dry_run"]) + self.assertEqual(result["sessions_to_delete"], len(ids)) + self.assertEqual(result["alerts_retracted"], 2) + self.assertEqual(result["connections_deleted"]["intros"], 1) + + summary = product_server.corpus_summary(runtime) + self.assertEqual(summary["sessions_by_kind"], {}) + self.assertEqual(summary["volunteer_sessions"], 0) + self.assertEqual(repo._fetchone_map("SELECT COUNT(*) AS n FROM intros")["n"], 0) + self.assertEqual(repo._fetchone_map("SELECT COUNT(*) AS n FROM messages")["n"], 0) + self.assertIsNone(repo.get_grant(ALERT_KIND, f"person-gone|{ids[0]}|{ids[1]}")) + + # what must survive + self.assertEqual(len(repo.list_users()), users_before) + self.assertIsNotNone(repo.get_grant("client_registration", "client-test")) + self.assertTrue(runtime.live.health().ok) + self.assertTrue(runtime.live.health().index_current) + finally: + runtime.live.repo.close() + + def test_a_second_run_finds_nothing_left_to_do(self): + runtime = _runtime() + try: + product_server.startup_purge_corpus(runtime, {"RESONANCE_PURGE_CORPUS": "1"}) + again = product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": "1"}) + self.assertEqual(again["sessions_to_delete"], 0) + self.assertEqual(again["alerts_retracted"], 0) + self.assertEqual(sum(again["connections_deleted"].values()), 0) + self.assertTrue(runtime.live.health().ok) + finally: + runtime.live.repo.close() + + def test_keep_is_refused_rather_than_ignored(self): + # An operator who set KEEP expects something to survive. Ignoring it + # would delete their exception and tell them afterwards. + runtime = _runtime() + try: + ids = [s.session_id for s in runtime.live.repo.list_sessions()] + result = product_server.startup_purge_corpus( + runtime, {"RESONANCE_PURGE_CORPUS": "1", "RESONANCE_PURGE_KEEP": ids[0]}) + self.assertIn("refused", result) + self.assertEqual(len([s for s in runtime.live.repo.list_sessions() + if s.deleted_at is None]), len(ids)) + finally: + runtime.live.repo.close() + + +class PurgeSessionsRetractsBothSidesTests(unittest.TestCase): + def test_deleting_a_thought_removes_the_alert_recorded_for_the_other_person(self): + # `retract_for_session` reaches only the owner's own side. The alert + # recorded for the person on the other end still named the deleted + # thought and survived until they next looked. + runtime = _runtime() + try: + repo = runtime.live.repo + ids = [s.session_id for s in repo.list_sessions()] + mine, theirs = ids[0], ids[1] + owner = repo.get_session(mine).user_id + counterpart = repo.get_session(theirs).user_id + my_key = _an_alert(runtime, owner, mine, theirs) + their_key = _an_alert(runtime, counterpart, theirs, mine) + + result = product_server.startup_purge_sessions( + runtime, {"RESONANCE_PURGE_SESSIONS": mine}) + self.assertEqual(result["deleted"], 1) + self.assertEqual(result["alerts_retracted"], 2) + self.assertIsNone(repo.get_grant(ALERT_KIND, my_key)) + self.assertIsNone(repo.get_grant(ALERT_KIND, their_key)) + finally: + runtime.live.repo.close() + + +if __name__ == "__main__": + unittest.main()