atlas-iac/testing/tests/test_hermes_hux_memory_repairs.py
jenkins 681b040885 hermes(hux): close Wave A review findings in events, memory, privacy and organization
F3 memory edits go through the same privacy shaping as proposals; F5 seq is
derived from the ledger tail so a crash between append and checkpoint never
duplicates; F7 transitions re-read under the lock and always write with the
loaded revision; F9 secret scrub on titles, passages, claims and notebooks
and forget blanks the conversation document; F13 no ghost conversations
from notices, idempotency under the lock, artifact titles searchable,
normalised paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00

210 lines
11 KiB
Python

"""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"}
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})
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