diff --git a/DEV-LEDGER.md b/DEV-LEDGER.md index 4e7974d..8955925 100644 --- a/DEV-LEDGER.md +++ b/DEV-LEDGER.md @@ -9,7 +9,7 @@ - **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z) - **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed) - **main_tip**: `999788b` -- **test_count**: 494 (prior 478 + 16 new Issue F asset/artifact/wiki tests) +- **test_count**: 509 (prior 494 + 15 new Issue E tests) - **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression) - **vectors**: 33,253 - **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity) @@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha ## Session Log +- **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes. + - **2026-04-21 Claude (cleanup)** One-time SQL cleanup on live Dalidou: flipped 8 `status='active' → 'invalid'` rows in `entities` (CGH, tower, "interferometer mirror tower", steel, "steel (likely)" in p05-interferometer + 3 remaining `AKC-E2E-Test-*` rows that were still active). Each update paired with a `memory_audit` row (action=`invalidated`, actor=`sql-cleanup`, note references Issue E pending). Executed inside the `atocore` container via `docker exec` since `/srv/storage/atocore/data/db/atocore.db` is root-owned and the service holds write perms. Verification: `GET /entities?project=p05-interferometer&scope_only=true` now 21 active, zero pollution. Issue E (public `POST /v1/entities/{id}/invalidate` for active→invalid) remains open — this cleanup should not be needed again once E ships. - **2026-04-21 Claude (evening)** Issue F (visual evidence) landed. New `src/atocore/assets/` module provides hash-dedup binary storage (`//.`) with on-demand JPEG thumbnails cached under `.thumbnails//`. New `assets` table (hash_sha256 unique, mime_type, size, width/height, source_refs, status). `artifact` added to `ENTITY_TYPES`; no schema change needed on entities (`properties` stays free-form JSON carrying `kind`/`asset_id`/`caption`/`capture_context`). `EVIDENCED_BY` already in the relationship enum — no change. New API: `POST /assets` (multipart, 20 MB cap, MIME allowlist: png/jpeg/webp/gif/pdf/step/iges), `GET /assets/{id}` (streams original), `GET /assets/{id}/thumbnail?size=N` (Pillow, 16-2048 px clamp), `GET /assets/{id}/meta`, `GET /admin/assets/orphans`, `DELETE /assets/{id}` (409 if referenced), `GET /entities/{id}/evidence` (returns EVIDENCED_BY artifacts with asset metadata resolved). All aliased under `/v1`. Wiki: artifact entity pages render full image + caption + capture_context; other entity pages render an "Visual evidence" strip of EVIDENCED_BY thumbnails linking to full-res + artifact detail page. PDFs render as a link; other artifact kinds render as labeled chips. Added `python-multipart` + `Pillow>=10.0.0` to deps; docker-compose gets an `${ATOCORE_ASSETS_DIR}` bind mount; Dalidou `.env` updated with `ATOCORE_ASSETS_DIR=/srv/storage/atocore/data/assets`. 16 new tests (hash dedup, size cap, mime allowlist, thumbnail cache, orphan detection, invalidate gating, multipart upload, evidence API, v1 aliases, wiki rendering). Tests 478 → 494. diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 207bb77..3c23868 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -773,6 +773,72 @@ def api_reject_candidate_memory(memory_id: str) -> dict: return {"status": "rejected", "id": memory_id} +class MemoryInvalidateRequest(BaseModel): + reason: str = "" + + +class MemorySupersedeRequest(BaseModel): + reason: str = "" + + +@router.post("/memory/{memory_id}/invalidate") +def api_invalidate_memory( + memory_id: str, + req: MemoryInvalidateRequest | None = None, +) -> dict: + """Retract an active memory (Issue E — active → invalid).""" + from atocore.memory.service import get_memories as _get_memories, invalidate_memory + + reason = req.reason if req else "" + # Quick existence/status check for a clean 404 vs 409. + existing = [ + m for m in _get_memories(status="active", limit=1) + if m.id == memory_id + ] + if not existing: + # Fall through to generic not-active if the id exists in another status. + all_match = [ + m for m in _get_memories(status="candidate", limit=5000) + + _get_memories(status="invalid", limit=5000) + + _get_memories(status="superseded", limit=5000) + if m.id == memory_id + ] + if all_match: + if all_match[0].status == "invalid": + return {"status": "already_invalid", "id": memory_id} + raise HTTPException( + status_code=409, + detail=( + f"Memory {memory_id} is {all_match[0].status}; " + "use /reject for candidates" + ), + ) + raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}") + + success = invalidate_memory(memory_id, actor="api-http", reason=reason) + if not success: + raise HTTPException(status_code=409, detail=f"Memory {memory_id} could not be invalidated") + return {"status": "invalidated", "id": memory_id} + + +@router.post("/memory/{memory_id}/supersede") +def api_supersede_memory( + memory_id: str, + req: MemorySupersedeRequest | None = None, +) -> dict: + """Supersede an active memory (Issue E — active → superseded).""" + from atocore.memory.service import supersede_memory + + reason = req.reason if req else "" + success = supersede_memory(memory_id, actor="api-http", reason=reason) + if not success: + raise HTTPException( + status_code=404, + detail=f"Memory not found or not active: {memory_id}", + ) + return {"status": "superseded", "id": memory_id} + + @router.post("/project/state") def api_set_project_state(req: ProjectStateSetRequest) -> dict: """Set or update a trusted project state entry.""" @@ -2131,6 +2197,79 @@ def api_reject_entity(entity_id: str) -> dict: return {"status": "rejected", "id": entity_id} +class EntityInvalidateRequest(BaseModel): + reason: str = "" + + +class EntitySupersedeRequest(BaseModel): + superseded_by: str + reason: str = "" + + +@router.post("/entities/{entity_id}/invalidate") +def api_invalidate_entity( + entity_id: str, + req: EntityInvalidateRequest | None = None, +) -> dict: + """Retract an active entity (Issue E — active → invalid). + + Idempotent: invalidating an already-invalid entity returns 200 with + ``status=already_invalid``. Use ``POST /entities/{id}/reject`` for + the distinct candidate→invalid transition. + """ + from atocore.engineering.service import invalidate_active_entity + + reason = req.reason if req else "" + ok, code = invalidate_active_entity( + entity_id, actor="api-http", reason=reason, + ) + if code == "not_found": + raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}") + if code == "not_active": + raise HTTPException( + status_code=409, + detail=( + f"Entity {entity_id} is not active; use " + "/reject for candidates or /supersede as appropriate" + ), + ) + return {"status": code, "id": entity_id} + + +@router.post("/entities/{entity_id}/supersede") +def api_supersede_entity( + entity_id: str, + req: EntitySupersedeRequest, +) -> dict: + """Supersede an active entity by a newer one (Issue E). + + Sets status to ``superseded`` and creates a ``supersedes`` + relationship from ``superseded_by`` → ``entity_id`` so the graph + reflects the replacement without a second API call. + """ + from atocore.engineering.service import supersede_entity + + try: + ok = supersede_entity( + entity_id, + actor="api-http", + note=req.reason, + superseded_by=req.superseded_by, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not ok: + raise HTTPException( + status_code=409, + detail=f"Entity {entity_id} cannot be superseded (already superseded or not active)", + ) + return { + "status": "superseded", + "id": entity_id, + "superseded_by": req.superseded_by, + } + + @router.get("/entities/{entity_id}/audit") def api_entity_audit(entity_id: str, limit: int = 100) -> dict: """Return the audit history for a specific entity.""" diff --git a/src/atocore/engineering/service.py b/src/atocore/engineering/service.py index be163d5..aaf77d0 100644 --- a/src/atocore/engineering/service.py +++ b/src/atocore/engineering/service.py @@ -462,9 +462,76 @@ def reject_entity_candidate(entity_id: str, actor: str = "api", note: str = "") return _set_entity_status(entity_id, "invalid", actor=actor, note=note) -def supersede_entity(entity_id: str, actor: str = "api", note: str = "") -> bool: - """Mark an active entity as superseded by a newer one.""" - return _set_entity_status(entity_id, "superseded", actor=actor, note=note) +def supersede_entity( + entity_id: str, + actor: str = "api", + note: str = "", + superseded_by: str | None = None, +) -> bool: + """Mark an active entity as superseded. + + When ``superseded_by`` names a real entity, also create a + ``supersedes`` relationship from the new entity to the old one + (semantics: ``new SUPERSEDES old``). This keeps the graph + navigable without the caller remembering to make that edge. + """ + if superseded_by: + new_entity = get_entity(superseded_by) + if new_entity is None: + raise ValueError( + f"superseded_by entity not found: {superseded_by}" + ) + if new_entity.id == entity_id: + raise ValueError("entity cannot supersede itself") + + ok = _set_entity_status(entity_id, "superseded", actor=actor, note=note) + if not ok: + return False + + if superseded_by: + try: + create_relationship( + source_entity_id=superseded_by, + target_entity_id=entity_id, + relationship_type="supersedes", + source_refs=[f"supersede-api:{actor}"], + ) + except Exception as e: + log.warning( + "supersede_relationship_create_failed", + entity_id=entity_id, + superseded_by=superseded_by, + error=str(e), + ) + return True + + +def invalidate_active_entity( + entity_id: str, + actor: str = "api", + reason: str = "", +) -> tuple[bool, str]: + """Mark an active entity as invalid (Issue E — retraction path). + + Returns (success, status_code) where status_code is one of: + - "invalidated" — happy path + - "not_found" — no such entity + - "already_invalid" — already invalid (idempotent) + - "not_active" — entity is candidate/superseded; use the + appropriate other endpoint + + This is the public retraction API distinct from + ``reject_entity_candidate`` (which only handles candidate→invalid). + """ + entity = get_entity(entity_id) + if entity is None: + return False, "not_found" + if entity.status == "invalid": + return True, "already_invalid" + if entity.status != "active": + return False, "not_active" + ok = _set_entity_status(entity_id, "invalid", actor=actor, note=reason) + return ok, "invalidated" if ok else "not_active" def get_entity_audit(entity_id: str, limit: int = 100) -> list[dict]: diff --git a/src/atocore/main.py b/src/atocore/main.py index 0247d15..c214b62 100644 --- a/src/atocore/main.py +++ b/src/atocore/main.py @@ -63,6 +63,8 @@ _V1_PUBLIC_PATHS = { "/entities/{entity_id}", "/entities/{entity_id}/promote", "/entities/{entity_id}/reject", + "/entities/{entity_id}/invalidate", + "/entities/{entity_id}/supersede", "/entities/{entity_id}/audit", "/relationships", "/ingest", @@ -79,6 +81,8 @@ _V1_PUBLIC_PATHS = { "/memory/{memory_id}/audit", "/memory/{memory_id}/promote", "/memory/{memory_id}/reject", + "/memory/{memory_id}/invalidate", + "/memory/{memory_id}/supersede", "/project/state", "/project/state/{project_name}", "/interactions", diff --git a/src/atocore/memory/service.py b/src/atocore/memory/service.py index caab3b4..c59f133 100644 --- a/src/atocore/memory/service.py +++ b/src/atocore/memory/service.py @@ -456,14 +456,26 @@ def update_memory( return False -def invalidate_memory(memory_id: str, actor: str = "api") -> bool: - """Mark a memory as invalid (error correction).""" - return update_memory(memory_id, status="invalid", actor=actor) +def invalidate_memory( + memory_id: str, + actor: str = "api", + reason: str = "", +) -> bool: + """Mark a memory as invalid (error correction). + + ``reason`` lands in the audit row's ``note`` column so future + review has a record of why this memory was retracted. + """ + return update_memory(memory_id, status="invalid", actor=actor, note=reason) -def supersede_memory(memory_id: str, actor: str = "api") -> bool: +def supersede_memory( + memory_id: str, + actor: str = "api", + reason: str = "", +) -> bool: """Mark a memory as superseded (replaced by newer info).""" - return update_memory(memory_id, status="superseded", actor=actor) + return update_memory(memory_id, status="superseded", actor=actor, note=reason) def promote_memory(memory_id: str, actor: str = "api", note: str = "") -> bool: diff --git a/tests/test_invalidate_supersede.py b/tests/test_invalidate_supersede.py new file mode 100644 index 0000000..be6d004 --- /dev/null +++ b/tests/test_invalidate_supersede.py @@ -0,0 +1,194 @@ +"""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"