feat(api): invalidate + supersede for active entities and memories (Issue E)
Public retraction path so mistakes can be corrected without SQL. Unblocks
the correction workflows that the live AKC p05 session exposed.
- engineering/service.py: invalidate_active_entity returns (ok, code)
with codes invalidated/already_invalid/not_active/not_found for clean
HTTP mapping. supersede_entity gains superseded_by + auto-creates the
supersedes relationship (new SUPERSEDES old), rejects self-supersede
- memory/service.py: invalidate_memory/supersede_memory accept reason
string that lands in audit note
- api/routes.py: POST /entities/{id}/invalidate, /supersede;
POST /memory/{id}/invalidate, /supersede (all 4 behind /v1 aliases)
- tests/test_invalidate_supersede.py: 15 tests (idempotency, 404/409,
supersede relationship auto-creation, self-supersede rejection,
missing-replacement rejection, v1 alias presence)
- DEV-LEDGER.md: session log + test_count 494 -> 509
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user