"""HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b). Security obligations exercised: SO-21 (deny topics never reach memory, even through an edit), SO-22 (no_store and forget are content-free and final), SO-23 (a forgotten entry never retrieves again), SO-28 (private mode refuses memory writes), SO-44 (revision checks hold under concurrency). """ from __future__ import annotations import json import sys import threading from pathlib import Path ROOT = Path(__file__).resolve().parents[2] FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) from hux import contracts, identity, memory, privacy, store # noqa: E402 from hux.errors import Conflict # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) USER = {"proposed_by": "user", "approval_mode": "automatic"} def ident() -> identity.Identity: return identity.Identity(tenant_slot="slot-3", subject="usr_0123456789abcdef", surface="chat", trust="router") def router_for(tmp_path): return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=None): raw = json.dumps(body).encode() if body is not None else b"" response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw) return response.status, response.body def tenant(tmp_path) -> store.TenantStore: return store.TenantStore(tmp_path, ident()) def conversation(router) -> str: status, body = call(router, "POST", "/hux/v1/conversations", {"title": "tea"}) assert status == 201 return body["id"] def active(router, conv, content="I like tea") -> dict: status, body = call(router, "POST", "/hux/v1/memory", {"content": content, "conversation_id": conv, **USER}) assert (status, body["status"]) == (201, "active"), body return body def valid(record) -> dict: assert contracts.validate_record(record, SCHEMAS) == [], record return record # --- F3: edit goes through the same shaping as a proposal --------------------------- def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original(tmp_path): """F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded.""" router = router_for(tmp_path) conv = conversation(router) original = active(router, conv) status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "my home address is 12 Main Street; my daughter is 6"}, {"If-Match": "1"}) assert status == 202 and valid(body)["status"] == "no_store" and body["content"] == "" and "topic" not in body assert body["sensitivity"] == "restricted" and body["retrievable"] is False and "supersedes" not in body assert body["audit"][0]["note"] == "restricted; topic=location" and "Main Street" not in json.dumps(body) assert body["id"] in memory.tombstoned(tenant(tmp_path)) status, again = call(router, "GET", f"/hux/v1/memory/{original['id']}") assert status == 200 and again["status"] == "active" and again["revision"] == 1 and again["content"] == "I like tea" assert [hit["content"] for hit in memory.retrieve(tenant(tmp_path), ["address"])] == [] assert [hit["id"] for hit in memory.retrieve(tenant(tmp_path), ["tea"])] == [original["id"]] kinds = [row["kind"] for row in tenant(tmp_path).read("events", conv)] assert kinds[-1] == "memory.suppressed" def test_f3_edit_with_sensitive_content_is_proposed_with_ask(tmp_path): """F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval.""" router = router_for(tmp_path) conv = conversation(router) original = active(router, conv) status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I take lithium for my diagnosis"}) assert status == 200 and valid(body)["status"] == "proposed" and body["approval_mode"] == "ask" assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["retrievable"] is False assert body["supersedes"] == original["id"] and [row["action"] for row in body["audit"]] == ["edited"] assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "forgotten" def test_f3_edit_scrubs_secrets_and_floors_credentials(tmp_path): """F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store.""" router = router_for(tmp_path) conv = conversation(router) original = active(router, conv) status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"}) assert status == 202 and body["status"] == "no_store" and body["sensitivity"] == "restricted" leaked = [path for path in tmp_path.rglob("*.json*") if "ghp_ABCDEFGHIJ" in path.read_text()] assert leaked == [] def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled(tmp_path): """F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store.""" router = router_for(tmp_path) conv = conversation(router) original = active(router, conv) privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", True) status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"}) assert status == 202 and body["status"] == "no_store" and body["audit"][0]["note"].startswith("memory_disabled") assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active" privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", False) assert call(router, "PATCH", f"/hux/v1/conversations/{conv}", {"mode": "private"})[0] == 200 status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"}) assert status == 403 and body["code"] == "forbidden" assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active" def test_f3_edit_still_needs_content(tmp_path): router = router_for(tmp_path) original = active(router, conversation(router)) assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": " "})[0] == 400 assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "x" * 2001})[0] == 400 # --- F7: a stale record cannot resurrect a forgotten entry ------------------------ def test_f7_stale_approve_after_forget_is_a_conflict(tmp_path, monkeypatch): """F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected.""" router = router_for(tmp_path) conv = conversation(router) status, proposed = call(router, "POST", "/hux/v1/memory", {"content": "I take lithium daily", "conversation_id": conv, "proposed_by": "user", "approval_mode": "ask"}) assert (status, proposed["status"]) == (201, "proposed") stale = dict(proposed) assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/reject")[1]["status"] == "rejected" monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale) status, body = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve") assert status == 409 and body["code"] == "conflict" monkeypatch.undo() status, after = call(router, "GET", f"/hux/v1/memory/{proposed['id']}") assert after["status"] == "rejected" and after["content"] == "" and after["revision"] == 2 assert memory.retrieve(tenant(tmp_path), ["lithium"]) == [] assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == [] def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget(tmp_path, monkeypatch): """F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works.""" router = router_for(tmp_path) conv = conversation(router) entry = active(router, conv, "I take lithium daily") stale = dict(entry) assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/forget")[1]["status"] == "forgotten" monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale) assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/remove_retrieval")[0] == 409 monkeypatch.undo() assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/restore_retrieval")[0] == 409 assert call(router, "GET", f"/hux/v1/memory/{entry['id']}")[1]["status"] == "forgotten" assert memory.retrieve(tenant(tmp_path), ["lithium"]) == [] def test_f7_transition_checks_if_match_against_the_fresh_copy(tmp_path): """F7 / SO-44: If-Match is compared with the stored revision, not the caller's stale snapshot.""" router = router_for(tmp_path) entry = active(router, conversation(router)) s = tenant(tmp_path) memory._set_retrievable(s, entry, False, {"type": "user", "id": "usr_0123456789abcdef"}, 1) try: memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}, expected=1) except Conflict as error: assert "revision 1 does not match current revision 2" in str(error) else: raise AssertionError("stale If-Match must conflict") assert memory.load(s, entry["id"])["status"] == "active" stored = memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}) assert stored["status"] == "forgotten" and stored["revision"] == 3 # --- F13b: Idempotency-Key is checked under the lock ------------------------------ def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record(tmp_path): """F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays.""" router = router_for(tmp_path) results: list[tuple[int, str]] = [] barrier = threading.Barrier(8) def propose() -> None: barrier.wait(5) status, body = call(router, "POST", "/hux/v1/memory", {"content": "I like tea", **USER}, {"Idempotency-Key": "same-key-0001"}) results.append((status, body["id"])) threads = [threading.Thread(target=propose) for _ in range(8)] for thread in threads: thread.start() for thread in threads: thread.join(10) assert sorted(status for status, _ in results) == [200] * 7 + [201] assert len({memory_id for _, memory_id in results}) == 1 assert len(call(router, "GET", "/hux/v1/memory")[1]["items"]) == 1 assert len(tenant(tmp_path).read(memory.IDEM_FAMILY, "keys")) == 1 def test_memory_module_stays_under_500_lines(): assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500