fix(memory): close Codex Wave 1 audit conditions (auto_triage + supersede guard)

Codex's formal audit of fb4d55c said GO WITH CONDITIONS. Two P2 findings
to fold in before merge:

1. auto_triage.py:417 still PUT {"content": cand["content"]} — the
   suggested-project correction was unreachable even with
   MemoryUpdateRequest.project in place. Changed body to
   {"project": suggested} so misattribution flags actually retarget the
   memory. Added a regression test that asserts the script source
   contains the new PUT shape, so a future "optimization" can't silently
   undo this.

2. POST /memory/{id}/supersede had no status guard — calling
   supersede_memory() delegated to update_memory(status="superseded"),
   which would silently flip a candidate to superseded. Mirrored the
   invalidate route: get_memory(id) lookup, 404 unknown / 200
   already_superseded / 409 wrong-status / 200 superseded.

Plus a P3 from the same audit: covered the "retarget to project=''
when a global active duplicate exists" case via
test_update_memory_to_empty_project_detects_global_duplicate.

Tests: 581 -> 586 (+5: 3 supersede route + 1 project-empty duplicate +
1 auto_triage caller invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:53:39 -04:00
parent 4e6fba7cb9
commit 3a474f750c
4 changed files with 91 additions and 8 deletions

View File

@@ -404,19 +404,23 @@ def process_candidate(cand, base_url, active_cache, state_cache, known_projects,
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S, known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
) )
# Project misattribution fix: suggested_project surfaces from tier 1 # Project misattribution fix: suggested_project surfaces from tier 1.
# Earlier code POSTed only {"content": cand["content"]}, which left
# the project field unchanged because MemoryUpdateRequest had no
# project key and the service signature didn't accept one. Wave 1
# added project to MemoryUpdateRequest and update_memory(); this
# caller now actually applies the suggested project.
suggested = (v1.get("suggested_project") or "").strip() suggested = (v1.get("suggested_project") or "").strip()
if suggested and suggested != project and suggested in known_projects: if suggested and suggested != project and suggested in known_projects:
# Try to re-canonicalize the memory's project
if not dry_run: if not dry_run:
try: try:
import urllib.request as _ur import urllib.request as _ur
req = _ur.Request( req = _ur.Request(
f"{base_url}/memory/{mid}", method="PUT", f"{base_url}/memory/{mid}", method="PUT",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
data=json.dumps({"content": cand["content"]}).encode("utf-8"), data=json.dumps({"project": suggested}).encode("utf-8"),
) )
_ur.urlopen(req, timeout=10).read() # triggers canonicalization via update _ur.urlopen(req, timeout=10).read()
except Exception: except Exception:
pass pass
print(f" ↺ misattribution flagged: {project!r}{suggested!r}") print(f" ↺ misattribution flagged: {project!r}{suggested!r}")

View File

@@ -827,15 +827,33 @@ def api_supersede_memory(
memory_id: str, memory_id: str,
req: MemorySupersedeRequest | None = None, req: MemorySupersedeRequest | None = None,
) -> dict: ) -> dict:
"""Supersede an active memory (Issue E — active → superseded).""" """Supersede an active memory (Issue E — active → superseded).
from atocore.memory.service import supersede_memory
Mirrors the invalidate route's status guard: candidates and other
non-active rows must not silently flip to superseded.
"""
from atocore.memory.service import get_memory, supersede_memory
reason = req.reason if req else "" reason = req.reason if req else ""
target = get_memory(memory_id)
if target is None:
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
if target.status == "superseded":
return {"status": "already_superseded", "id": memory_id}
if target.status != "active":
raise HTTPException(
status_code=409,
detail=(
f"Memory {memory_id} is {target.status}; "
"only active memories can be superseded"
),
)
success = supersede_memory(memory_id, actor="api-http", reason=reason) success = supersede_memory(memory_id, actor="api-http", reason=reason)
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=404, status_code=409,
detail=f"Memory not found or not active: {memory_id}", detail=f"Memory {memory_id} could not be superseded",
) )
return {"status": "superseded", "id": memory_id} return {"status": "superseded", "id": memory_id}

View File

@@ -249,6 +249,35 @@ def test_api_invalidate_unknown_id_is_404(env):
assert r.status_code == 404 assert r.status_code == 404
def test_api_supersede_candidate_returns_409(env):
"""Mirror of the invalidate guard: candidates must not silently flip
to superseded via the active-only supersede route."""
m = create_memory(
memory_type="knowledge", content="candidate target", status="candidate"
)
client = TestClient(app)
r = client.post(f"/memory/{m.id}/supersede", json={"reason": "wrong route"})
assert r.status_code == 409
# Row should still be a candidate
assert _get_memory(m.id).status == "candidate"
def test_api_supersede_already_superseded_is_idempotent(env):
m = create_memory(memory_type="knowledge", content="will be superseded")
client = TestClient(app)
r1 = client.post(f"/memory/{m.id}/supersede", json={"reason": "first"})
assert r1.status_code == 200
r2 = client.post(f"/memory/{m.id}/supersede", json={"reason": "again"})
assert r2.status_code == 200
assert r2.json()["status"] == "already_superseded"
def test_api_supersede_unknown_id_is_404(env):
client = TestClient(app)
r = client.post("/memory/no-such-id/supersede", json={"reason": "ghost"})
assert r.status_code == 404
def test_admin_dashboard_active_count_matches_full_table(env): def test_admin_dashboard_active_count_matches_full_table(env):
"""/admin/dashboard memories.active must match the SQL aggregate even """/admin/dashboard memories.active must match the SQL aggregate even
when there are more active memories than the legacy sample limit (500). when there are more active memories than the legacy sample limit (500).

View File

@@ -661,3 +661,35 @@ def test_update_memory_project_unchanged_when_not_passed(isolated_db):
mem = create_memory("knowledge", "untouched project", project="p06-polisher") mem = create_memory("knowledge", "untouched project", project="p06-polisher")
update_memory(mem.id, content="edited content") update_memory(mem.id, content="edited content")
assert get_memory(mem.id).project == "p06-polisher" assert get_memory(mem.id).project == "p06-polisher"
def test_update_memory_to_empty_project_detects_global_duplicate(isolated_db):
"""Codex P3: when retargeting to project='' (global), the duplicate
check must scope to the new project. If a global active memory with
the same content already exists, the update must raise."""
import pytest as _pytest
from atocore.memory.service import create_memory, update_memory
create_memory("knowledge", "shared global fact", project="")
scoped = create_memory("knowledge", "shared global fact", project="p04-gigabit")
with _pytest.raises(ValueError, match="duplicate active memory"):
update_memory(scoped.id, project="")
def test_auto_triage_suggested_project_put_body_uses_project_key():
"""Regression: the auto_triage caller used to PUT {"content": ...}
which silently dropped the suggested project change. The fix sends
{"project": suggested}. Inspect the script source so we don't have
to spin up a live triage run."""
from pathlib import Path
src = Path(__file__).resolve().parents[1] / "scripts" / "auto_triage.py"
text = src.read_text(encoding="utf-8")
# The block that PUTs to /memory/{mid} for a suggested_project fix
assert 'json.dumps({"project": suggested})' in text, (
"auto_triage.py must PUT {\"project\": suggested} so the "
"suggested-project correction actually applies. See Wave 1."
)
# And must not be back to the old shape
assert 'json.dumps({"content": cand["content"]})' not in text