2026-04-21 21:56:24 -04:00
|
|
|
"""Issue E — /invalidate + /supersede for active entities and memories."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from atocore.engineering.service import (
|
|
|
|
|
create_entity,
|
|
|
|
|
get_entity,
|
|
|
|
|
get_relationships,
|
|
|
|
|
init_engineering_schema,
|
|
|
|
|
invalidate_active_entity,
|
|
|
|
|
supersede_entity,
|
|
|
|
|
)
|
|
|
|
|
from atocore.main import app
|
|
|
|
|
from atocore.memory.service import create_memory, get_memories
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_memory(memory_id):
|
|
|
|
|
for status in ("active", "candidate", "invalid", "superseded"):
|
|
|
|
|
for m in get_memories(status=status, active_only=False, limit=5000):
|
|
|
|
|
if m.id == memory_id:
|
|
|
|
|
return m
|
|
|
|
|
return None
|
|
|
|
|
from atocore.models.database import init_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def env(tmp_data_dir, tmp_path, monkeypatch):
|
|
|
|
|
registry_path = tmp_path / "test-registry.json"
|
|
|
|
|
registry_path.write_text('{"projects": []}', encoding="utf-8")
|
|
|
|
|
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
|
|
|
|
|
from atocore import config
|
|
|
|
|
config.settings = config.Settings()
|
|
|
|
|
init_db()
|
|
|
|
|
init_engineering_schema()
|
|
|
|
|
yield tmp_data_dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_active_entity_transitions_to_invalid(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="tower-to-kill")
|
|
|
|
|
ok, code = invalidate_active_entity(e.id, reason="duplicate")
|
|
|
|
|
assert ok is True
|
|
|
|
|
assert code == "invalidated"
|
|
|
|
|
assert get_entity(e.id).status == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_on_candidate_is_409(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="still-candidate", status="candidate")
|
|
|
|
|
ok, code = invalidate_active_entity(e.id)
|
|
|
|
|
assert ok is False
|
|
|
|
|
assert code == "not_active"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_is_idempotent_on_invalid(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="already-gone")
|
|
|
|
|
invalidate_active_entity(e.id)
|
|
|
|
|
ok, code = invalidate_active_entity(e.id)
|
|
|
|
|
assert ok is True
|
|
|
|
|
assert code == "already_invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_supersede_creates_relationship(env):
|
|
|
|
|
old = create_entity(entity_type="component", name="old-tower")
|
|
|
|
|
new = create_entity(entity_type="component", name="new-tower")
|
|
|
|
|
ok = supersede_entity(old.id, superseded_by=new.id, note="replaced")
|
|
|
|
|
assert ok is True
|
|
|
|
|
assert get_entity(old.id).status == "superseded"
|
|
|
|
|
|
|
|
|
|
rels = get_relationships(new.id, direction="outgoing")
|
|
|
|
|
assert any(
|
|
|
|
|
r.relationship_type == "supersedes" and r.target_entity_id == old.id
|
|
|
|
|
for r in rels
|
|
|
|
|
), "supersedes relationship must be auto-created"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_supersede_rejects_self():
|
|
|
|
|
# no db needed — validation is pre-write. Using a fresh env anyway.
|
|
|
|
|
pass # covered below via API test
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_entity(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="api-kill")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/entities/{e.id}/invalidate",
|
|
|
|
|
json={"reason": "test cleanup"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "invalidated"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_entity_idempotent(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="api-kill-2")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
client.post(f"/entities/{e.id}/invalidate", json={"reason": "first"})
|
|
|
|
|
r = client.post(f"/entities/{e.id}/invalidate", json={"reason": "second"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "already_invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_unknown_entity_is_404(env):
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
"/entities/nonexistent-id/invalidate",
|
|
|
|
|
json={"reason": "missing"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_candidate_entity_is_409(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="cand", status="candidate")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(f"/entities/{e.id}/invalidate", json={"reason": "x"})
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_supersede_entity(env):
|
|
|
|
|
old = create_entity(entity_type="component", name="api-old-tower")
|
|
|
|
|
new = create_entity(entity_type="component", name="api-new-tower")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/entities/{old.id}/supersede",
|
|
|
|
|
json={"superseded_by": new.id, "reason": "dedup"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["status"] == "superseded"
|
|
|
|
|
assert body["superseded_by"] == new.id
|
|
|
|
|
assert get_entity(old.id).status == "superseded"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_supersede_self_is_400(env):
|
|
|
|
|
e = create_entity(entity_type="component", name="self-sup")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/entities/{e.id}/supersede",
|
|
|
|
|
json={"superseded_by": e.id, "reason": "oops"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_supersede_missing_replacement_is_400(env):
|
|
|
|
|
old = create_entity(entity_type="component", name="orphan-old")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/entities/{old.id}/supersede",
|
|
|
|
|
json={"superseded_by": "does-not-exist", "reason": "missing"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_memory(env):
|
|
|
|
|
m = create_memory(
|
|
|
|
|
memory_type="project",
|
|
|
|
|
content="memory to retract",
|
|
|
|
|
project="p05",
|
|
|
|
|
)
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/memory/{m.id}/invalidate",
|
|
|
|
|
json={"reason": "outdated"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "invalidated"
|
|
|
|
|
assert _get_memory(m.id).status == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_supersede_memory(env):
|
|
|
|
|
m = create_memory(
|
|
|
|
|
memory_type="project",
|
|
|
|
|
content="memory to supersede",
|
|
|
|
|
project="p05",
|
|
|
|
|
)
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(
|
|
|
|
|
f"/memory/{m.id}/supersede",
|
|
|
|
|
json={"reason": "replaced by newer fact"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "superseded"
|
|
|
|
|
assert _get_memory(m.id).status == "superseded"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_v1_aliases_present(env):
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
spec = client.get("/openapi.json").json()
|
|
|
|
|
paths = spec["paths"]
|
|
|
|
|
for p in (
|
|
|
|
|
"/v1/entities/{entity_id}/invalidate",
|
|
|
|
|
"/v1/entities/{entity_id}/supersede",
|
|
|
|
|
"/v1/memory/{memory_id}/invalidate",
|
|
|
|
|
"/v1/memory/{memory_id}/supersede",
|
|
|
|
|
):
|
|
|
|
|
assert p in paths, f"{p} missing"
|
fix(memory): SQL-aggregate dashboard counts, project on update, id-based invalidate
Three bugs surfaced by the 2026-04-29 Codex review of the state-of-the-service
plan, all in the memory write/read path:
1. /admin/dashboard memory counts were derived from a confidence-sorted
get_memories(limit=500) sample. With prod at 1091 active memories the
dashboard reported 315 ("active in the top 500"), while integrity
reported the SQL aggregate 1091. Replaced the sampling block with a
new get_memory_count_summary() helper that does straight SQL aggregates
over status/type/project. Dashboard memories.{active,candidates,...}
now match integrity. Adds memories.{by_status,total} for completeness.
2. PUT /memory/{id} silently dropped project changes because
MemoryUpdateRequest had no project field and update_memory() didn't
accept one. auto_triage.py:407 detects suggested_project drift and
issues a PUT to fix it; the fix never landed. Added project to the
request schema and the service signature, with resolve_project_name
canonicalization, before/after audit snapshot, and the existing
duplicate-active check now scoped to the new project.
3. POST /memory/{id}/invalidate did _get_memories(status="active",
limit=1) and looked for the target inside that single
highest-confidence row. Any other active memory 404'd. Replaced with
a direct id lookup via the new get_memory(id) helper; status branching
stays the same (404 unknown / 200 already-invalid / 409 wrong-status /
200 invalidated).
Tests added (9):
- test_get_memory_count_summary_returns_full_table_aggregates
- test_get_memory_returns_single_row_or_none
- test_update_memory_can_change_project_with_canonicalization
- test_update_memory_project_unchanged_when_not_passed
- test_api_invalidate_finds_active_memory_outside_top_one
- test_api_invalidate_already_invalid_is_idempotent
- test_api_invalidate_candidate_returns_409
- test_api_invalidate_unknown_id_is_404
- test_admin_dashboard_active_count_matches_full_table
Test count: 572 -> 581. Full suite green locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:40:10 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Wave 1 (2026-04-29) — invalidation route used to do
|
|
|
|
|
# `_get_memories(status='active', limit=1)` and look for the target id
|
|
|
|
|
# inside that single highest-confidence row, so any active memory
|
|
|
|
|
# outside slot 0 fell through as 404. Direct id lookup fixes it.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_finds_active_memory_outside_top_one(env):
|
|
|
|
|
"""An active memory not at the top of the confidence sort must still
|
|
|
|
|
be invalidatable via POST /memory/{id}/invalidate."""
|
|
|
|
|
high = create_memory(
|
|
|
|
|
memory_type="knowledge",
|
|
|
|
|
content="high-confidence top row",
|
|
|
|
|
confidence=0.99,
|
|
|
|
|
)
|
|
|
|
|
low = create_memory(
|
|
|
|
|
memory_type="knowledge",
|
|
|
|
|
content="lower-confidence target",
|
|
|
|
|
confidence=0.55,
|
|
|
|
|
)
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(f"/memory/{low.id}/invalidate", json={"reason": "wave1 regression"})
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
assert r.json()["status"] == "invalidated"
|
|
|
|
|
# And confirm the high-confidence row is untouched
|
|
|
|
|
assert _get_memory(high.id).status == "active"
|
|
|
|
|
assert _get_memory(low.id).status == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_already_invalid_is_idempotent(env):
|
|
|
|
|
m = create_memory(memory_type="knowledge", content="already invalid")
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r1 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "first"})
|
|
|
|
|
assert r1.status_code == 200
|
|
|
|
|
r2 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "again"})
|
|
|
|
|
assert r2.status_code == 200
|
|
|
|
|
assert r2.json()["status"] == "already_invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_candidate_returns_409(env):
|
|
|
|
|
m = create_memory(
|
|
|
|
|
memory_type="knowledge", content="candidate route", status="candidate"
|
|
|
|
|
)
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post(f"/memory/{m.id}/invalidate", json={"reason": "wrong route"})
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_invalidate_unknown_id_is_404(env):
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
r = client.post("/memory/no-such-id/invalidate", json={"reason": "ghost"})
|
|
|
|
|
assert r.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 21:53:39 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
fix(memory): SQL-aggregate dashboard counts, project on update, id-based invalidate
Three bugs surfaced by the 2026-04-29 Codex review of the state-of-the-service
plan, all in the memory write/read path:
1. /admin/dashboard memory counts were derived from a confidence-sorted
get_memories(limit=500) sample. With prod at 1091 active memories the
dashboard reported 315 ("active in the top 500"), while integrity
reported the SQL aggregate 1091. Replaced the sampling block with a
new get_memory_count_summary() helper that does straight SQL aggregates
over status/type/project. Dashboard memories.{active,candidates,...}
now match integrity. Adds memories.{by_status,total} for completeness.
2. PUT /memory/{id} silently dropped project changes because
MemoryUpdateRequest had no project field and update_memory() didn't
accept one. auto_triage.py:407 detects suggested_project drift and
issues a PUT to fix it; the fix never landed. Added project to the
request schema and the service signature, with resolve_project_name
canonicalization, before/after audit snapshot, and the existing
duplicate-active check now scoped to the new project.
3. POST /memory/{id}/invalidate did _get_memories(status="active",
limit=1) and looked for the target inside that single
highest-confidence row. Any other active memory 404'd. Replaced with
a direct id lookup via the new get_memory(id) helper; status branching
stays the same (404 unknown / 200 already-invalid / 409 wrong-status /
200 invalidated).
Tests added (9):
- test_get_memory_count_summary_returns_full_table_aggregates
- test_get_memory_returns_single_row_or_none
- test_update_memory_can_change_project_with_canonicalization
- test_update_memory_project_unchanged_when_not_passed
- test_api_invalidate_finds_active_memory_outside_top_one
- test_api_invalidate_already_invalid_is_idempotent
- test_api_invalidate_candidate_returns_409
- test_api_invalidate_unknown_id_is_404
- test_admin_dashboard_active_count_matches_full_table
Test count: 572 -> 581. Full suite green locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:40:10 -04:00
|
|
|
def test_admin_dashboard_active_count_matches_full_table(env):
|
|
|
|
|
"""/admin/dashboard memories.active must match the SQL aggregate even
|
|
|
|
|
when there are more active memories than the legacy sample limit (500).
|
|
|
|
|
|
|
|
|
|
This guards the Codex finding that the dashboard was deriving counts
|
|
|
|
|
from a confidence-sorted limit=500 fetch, hiding rows past the cap.
|
|
|
|
|
We don't need 500 rows in the test — a small corpus that exercises
|
|
|
|
|
the SQL-aggregate path is enough; the integrity-vs-dashboard equality
|
|
|
|
|
is the invariant being asserted.
|
|
|
|
|
"""
|
|
|
|
|
# Mix of statuses to exercise the by_status aggregate
|
|
|
|
|
create_memory(memory_type="knowledge", content="a")
|
|
|
|
|
create_memory(memory_type="knowledge", content="b", project="p06-polisher")
|
|
|
|
|
create_memory(memory_type="project", content="c-cand", status="candidate")
|
|
|
|
|
cand = create_memory(memory_type="project", content="d-cand", status="candidate")
|
|
|
|
|
# Invalidate one to seed an "invalid" bucket
|
|
|
|
|
from atocore.memory.service import invalidate_memory
|
|
|
|
|
target_id = cand.id
|
|
|
|
|
# Promote it first via direct DB so invalidate does flip a candidate
|
|
|
|
|
# to invalid via the service path (mirrors actual API trajectory).
|
|
|
|
|
invalidate_memory(target_id)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
dash = client.get("/admin/dashboard").json()
|
|
|
|
|
assert dash["memories"]["active"] == 2
|
|
|
|
|
assert dash["memories"]["candidates"] == 1
|
|
|
|
|
assert dash["memories"]["by_status"]["invalid"] == 1
|
|
|
|
|
assert dash["memories"]["total"] == 4
|
|
|
|
|
assert dash["memories"]["by_project"].get("p06-polisher") == 1
|
|
|
|
|
# "(none)" bucket is the COALESCE label for empty/null project
|
|
|
|
|
assert "(none)" in dash["memories"]["by_project"]
|