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:
@@ -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 (`<assets_dir>/<hash[:2]>/<hash>.<ext>`) with on-demand JPEG thumbnails cached under `.thumbnails/<size>/`. 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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
194
tests/test_invalidate_supersede.py
Normal file
194
tests/test_invalidate_supersede.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user