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:
2026-04-21 21:56:24 -04:00
parent 069d155585
commit 081c058f77
6 changed files with 427 additions and 9 deletions

View File

@@ -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."""

View File

@@ -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]:

View File

@@ -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",

View File

@@ -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: