diff --git a/CHANGELOG.md b/CHANGELOG.md index 78212679..71ab30d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ErasureCoordinator` completes the erasure workflow `purge_node()` only starts — the graph node was removed while the same content survived verbatim in `AgentMemory` and as an embedding** (closes #1018) by @pravit-amp + - New `semantica/context/erasure.py`, exporting `ErasureCoordinator` and `ErasureReceipt` from `semantica.context`. `purge_node()`/`purge_edge()` (#957) are graph-scope by design and their changelog entry documents this gap explicitly; the changelog also names GDPR Article 17 as the motivation, and an Article 17 erasure that removes the node while the content stays retrievable by similarity search is not an erasure — it is worse than not offering one, because `purge_node()` returns `True` and writes a tombstone attesting the content is gone + - The coordinator **composes** the existing public APIs — nothing in `context_graph.py` or `agent_memory.py` changes behaviorally, and `ContextGraph` keeps its documented graph-scope contract rather than acquiring references to `AgentMemory`/`vector_store` that would invert the dependency + - `erase_entity(entity_id, reason=..., at=..., vector_ids=...)` returns an `ErasureReceipt`; `erase_entities([...])` returns one receipt per entity, in order, so one entity's failure does not stop the rest + - **Honest partial reporting is the point.** Each store reports one of five statuses — `erased`, `not_found`, `not_configured` (store never bound; normal), `unsupported` (store cannot delete at all; retrying will not help), `failed` — and `receipt.complete` is `False` when any store reports `unsupported`/`failed`, with `receipt.incomplete_stores` naming them. A receipt reading `graph: erased, memory: 14 erased, vectors: unsupported on faiss` is actionable; a bare `True` is a compliance liability + - **Erasure runs outward-in: vectors → memory → graph.** The graph tombstone is the durable attestation that an erasure happened, so writing it first would let a crash mid-cascade leave a record claiming more than occurred. Erasing the graph last means a partial failure leaves the node present and the receipt incomplete — recoverable and honest; the reverse is neither + - **Partial failure is a result, not an exception**: a store that raises is recorded as `failed` (with the exception type) and the remaining legs still run, rather than aborting into a half-erased state with no record of which half + - **The memory sweep cannot be silently truncated.** `find_by_entity(entity_id, limit=10)` returned `results[:limit]`, so the obvious hand-rolled cascade erases the first ten items and reports success — an erasure check computed from a page already truncated by the very `limit` it was called with. The coordinator sweeps in pages until dry (deleting as it goes, so the next page is the remainder) rather than passing one large number that is only correct until someone exceeds it, then **re-queries once after the sweep** and reports `failed` with the residual count if anything survived. It also stops rather than spinning if `batch_delete` reports no progress on a non-empty page. Note `find_by_entity` returns items keyed `memory_id`, not `id` + - **`unsupported` vector backends are detected by probing, not by calling and catching.** `faiss_store.py`, `milvus_store.py` and `weaviate_store.py` expose no delete at all (FAISS cannot remove from a flat index without a rebuild), while the `VectorStore` facade declares `delete_vectors()` for *every* backend and only raises `NotImplementedError` once called — so probing the facade alone cannot tell a deletable backend from a delete-less one, and the coordinator looks at the backend it wraps. Probing also keeps a missing method distinguishable from an `AttributeError` raised *inside* a working one, which is exactly where guessing wrong produces a false clean bill of health. `NotImplementedError` at call time is still caught and reported as `unsupported`; a store returning `False` is reported as `failed` + - Backends are reached under either supported name — `delete_vectors(ids)` (pinecone/qdrant) or `delete(ids)` (pgvector/sqlite-vec) — and the receipt records which was used + - `vector_store` defaults to `memory.vector_store` when a memory is supplied, stays overridable for deployments binding a store the memory does not own, and accepts `False` to disable the vector leg. Vectors owned by memory items are removed by the memory leg's own `delete_memory()` cascade; the explicit vector leg covers entity-keyed embeddings written by something other than `AgentMemory` + - The receipt's `erased_at` is normalized through `ContextGraph`'s own temporal normalizer, so the receipt and the tombstone written by the same erasure cannot disagree about when it happened; an unparseable `at` is rejected before any store is touched rather than half way through the cascade + - `purge_node()`'s docstring now points at the coordinator, so callers reading the graph-scope caveat find the thing that completes the workflow + - New `tests/context/test_erasure_coordinator.py`: 39 tests against **real** `ContextGraph`/`AgentMemory` instances rather than mocks — the bug lives in the interaction between them, so mocking it away would test nothing. Covers the 25-items-on-one-entity regression that fails against a naive single `find_by_entity()` call, all three vector-backend shapes (`delete_vectors`/`delete`/neither) plus the facade-over-delete-less-backend shape, residual/no-progress/no-identifier memory failures, partial failure continuing the cascade, idempotency, receipt serialization, and `at` normalization + - Full `tests/context/` suite: 608 passed + - **Fixed during review** (Qodo): `erase_entity()` resolved `erased_at` up front but passed the caller's original `at` down to `purge_node()`, so on the default `at=None` path the coordinator and the graph each took their own `now()` and the receipt attested to a different instant than the tombstone it points at — breaking the one invariant this module states most loudly. The resolved timestamp is now passed to the graph. The existing test passed only because it supplied an explicit `at`, which hides the drift; a regression test now covers the `at=None` path that callers actually use + - **Fixed during review** (Qodo): the vectors leg treated any return value other than the literal `False` as success, but no in-repo backend returns a bool — Qdrant returns `{"status": }` and Pinecone `{"deleted": True}`, so every dict was read as a success and the backend's own account of the delete was discarded. Delete results are now interpreted by shape (bool, dict with explicit failure markers, `None` for a void method, anything else at face value) and the backend payload is recorded in the receipt as `backend_result`, stringified so the receipt stays JSON-serializable as the audit record it is meant to be. Bool markers are matched by identity so a `0` count is not read as `False`, and string markers match as substrings so an enum rendering as `"UpdateStatus.FAILED"` is not read as a success + - **Fixed during review** (Qodo): the constructor's "at least one store" guard used `not vector_store`, rejecting a valid store whose `__bool__`/`__len__` makes an empty instance falsey, and reporting `vector_store=None` in the error when an object had been passed; it now distinguishes `None` (absent) from `False` (deliberately disabled) from any other value (provided), and echoes what it actually received + - **Fixed during review** (Qodo): `at` annotations accepted only `str`/`datetime` while the shared `ContextGraph` normalizer they delegate to also takes epoch seconds; widened to `int`/`float` with the docstrings updated, so the coordinator no longer advertises less than the graph API it wraps + - **Known limitation, unchanged by this PR**: erasure still cannot be *completed* on FAISS/Milvus/Weaviate — `delete_vectors()` is declared on the `VectorStore` facade (`vector_store.py:786`) but not implemented across the backend set, under at least three different names. That is worth its own issue; the coordinator ships reporting `unsupported` and starts reporting `erased` for those backends once it is fixed, with no API change here + - **First-class CrewAI integration** (#962) - New `pip install semantica[crewai]` extra (`crewai>=0.80.0`) — crewai core provides `BaseTool`/`BaseKnowledgeSource`, so `crewai-tools` is intentionally not included, and the extra is intentionally **not** part of the `all` bundle: crewai hard-requires `chromadb~=1.1.0`, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 (see `integrations/crewai/README.md`) - `integrations/crewai/SemanticaKGTool` — a CrewAI `BaseTool` exposing 5 KG actions (`extract_entities`, `extract_relations`, `add_to_graph`, `query_graph`, `find_related`) backed by `NERExtractor` / `RelationExtractor` / `ContextGraph`; supports both sync `run()` and async `arun()` diff --git a/docs/reference/context.md b/docs/reference/context.md index 18950e9e..dc5e64cf 100644 --- a/docs/reference/context.md +++ b/docs/reference/context.md @@ -25,6 +25,7 @@ icon: "brain" | `DecisionRecorder` | Record decisions with embeddings, causal chains, and metadata | | `PolicyEngine` | Policy management: `add_policy()`, `check_compliance()`, `get_applicable_policies()` | | `CausalChainAnalyzer` | Trace how decisions influenced each other: `get_causal_chain(decision_id)` | +| `ErasureCoordinator` | Erase an entity across graph, memory, and vector store, returning an auditable `ErasureReceipt` | ## What You Get @@ -632,6 +633,100 @@ queried together safely. Vector-store writes are deferred until the in-memory im commits; adapter synchronization remains best-effort and logs failures. +## ErasureCoordinator + +`ContextGraph.purge_node()` is scoped to one graph: the node is removed and a +tombstone is written, but the same content can still be live as an `AgentMemory` +item and as an embedding in the vector store. `ErasureCoordinator` drives the +cascade across every bound store and returns an `ErasureReceipt` recording what +each one reported. + +```python +from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator + +coordinator = ErasureCoordinator(graph=graph, memory=memory) + +receipt = coordinator.erase_entity( + "customer-4471", + reason="GDPR Art. 17 request #882", +) + +if not receipt.complete: + # These stores may still hold the entity; handle them out of band. + print(receipt.incomplete_stores) +``` + + +Check the receipt — the call returning is not proof the data is gone. FAISS, +Milvus, and Weaviate expose no delete method, so erasure cannot be completed on +those backends today; the receipt reports `unsupported` rather than a success it +did not achieve. + + +### Constructor Parameters + +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `graph` | `ContextGraph` | `None` | Anything exposing `purge_node()` | +| `memory` | `AgentMemory` | `None` | Anything exposing `find_by_entity()` and `batch_delete()` | +| `vector_store` | `VectorStore` | `memory.vector_store` | Store holding entity-keyed embeddings; pass `False` to disable the leg | + +At least one store is required; a store that is not supplied reports +`not_configured` rather than being silently skipped. + +### Methods + +| Method | Returns | Description | +| :--- | :--- | :--- | +| `erase_entity(entity_id, reason, at, vector_ids)` | `ErasureReceipt` | Erase one entity from every bound store | +| `erase_entities(entity_ids, reason, at)` | `List[ErasureReceipt]` | One receipt per entity, in order; one failure does not stop the rest | + +### Store Statuses + +| Status | Meaning | +| :--- | :--- | +| `erased` | Reached, data removed. On the vectors leg this means the store accepted the delete for the ids given — backends offer no portable existence check, so it is not a count of embeddings that were really there | +| `not_found` | Reached, held nothing for this entity | +| `not_configured` | No such store was bound — normal, not a failure | +| `unsupported` | The store cannot delete at all; retrying will not help | +| `failed` | The store was reached and the deletion did not succeed | + +### ErasureReceipt + +| Member | Type | Description | +| :--- | :--- | :--- | +| `entity_id` | `str` | Entity the erasure was requested for | +| `reason` | `Optional[str]` | Recorded in the receipt and the graph tombstone | +| `erased_at` | `str` | ISO-8601; matches the tombstone's `purged_at` | +| `stores` | `Dict[str, Dict]` | Per-store outcome keyed `vectors`, `memory`, `graph` | +| `complete` | `bool` | `False` when any store reports `unsupported` or `failed` | +| `incomplete_stores` | `List[str]` | Stores that may still hold the entity's data | +| `to_dict()` | `Dict` | Serialized receipt, safe to persist as an audit record | + +```python +receipt.to_dict() +# { +# "entity_id": "customer-4471", +# "reason": "GDPR Art. 17 request #882", +# "erased_at": "2026-08-16T09:03:36.813220", +# "complete": False, +# "stores": { +# "vectors": {"status": "unsupported", "backend": "faiss", +# "detail": "backend exposes no delete()/delete_vectors(); ..."}, +# "memory": {"status": "erased", "items": 14}, +# "graph": {"status": "erased", "nodes": 1, "edges": 3}, +# }, +# } +``` + +Erasure runs outward-in — vectors, then memory, then the graph. The tombstone is +the durable attestation that an erasure happened, so it is written last: a crash +mid-cascade leaves the node present and the receipt incomplete, rather than a +tombstone claiming more than actually happened. A store that raises is recorded +as `failed` and the remaining stores are still erased. Erasing the same entity +twice returns a receipt saying there was nothing left to do rather than raising. + + ## PolicyEngine `PolicyEngine` manages versioned policies stored in the knowledge graph. Policies are stored as nodes and can be linked to decisions: diff --git a/semantica/context/__init__.py b/semantica/context/__init__.py index c522e4f7..0e0b8c4e 100644 --- a/semantica/context/__init__.py +++ b/semantica/context/__init__.py @@ -111,6 +111,7 @@ from .context_retriever import ContextRetriever, RetrievedContext, TemporalGraphRetriever from .decision_context import DecisionContext from .entity_linker import EntityLink, EntityLinker, LinkedEntity +from .erasure import ErasureCoordinator, ErasureReceipt # Decision tracking imports from .decision_models import ( @@ -145,6 +146,9 @@ "ContextRetriever", "RetrievedContext", "TemporalGraphRetriever", + # Cross-store erasure + "ErasureCoordinator", + "ErasureReceipt", # Decision tracking models "Decision", "DecisionContextModel", diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index 28b431ef..951f1de3 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -1731,7 +1731,11 @@ def purge_node( Scope is this graph only. Copies held elsewhere (``AgentMemory``, a bound vector store, an exported file) are not reached, so this is one - step of an erasure workflow, not the whole of it. + step of an erasure workflow, not the whole of it. Callers who need the + whole workflow -- and a receipt recording which stores it actually + reached -- should drive this through + :class:`~semantica.context.erasure.ErasureCoordinator` rather than + treating a ``True`` here as proof the content is gone. Args: node_id: Node to purge. diff --git a/semantica/context/context_usage.md b/semantica/context/context_usage.md index 4e7ac56e..434d22db 100644 --- a/semantica/context/context_usage.md +++ b/semantica/context/context_usage.md @@ -239,6 +239,82 @@ print(f"Python importance score: {importance.get('degree', 0)}") --- +## 🧹 Erasing an Entity Everywhere - ErasureCoordinator + +`purge_node()` removes an entity from **one graph**. The same content can still be +sitting in agent memory and in your vector store, so purge on its own is one step +of an erasure workflow rather than the whole of it. + +`ErasureCoordinator` drives the whole cascade and hands you a receipt saying what +it actually managed to erase. + +```python +from semantica.context import AgentMemory, ContextGraph, ErasureCoordinator + +coordinator = ErasureCoordinator(graph=knowledge, memory=memory) + +receipt = coordinator.erase_entity( + "customer-4471", + reason="GDPR Art. 17 request #882", +) + +if receipt.complete: + print("Erased everywhere") +else: + print("Still holding data:", receipt.incomplete_stores) +``` + +### Always Check the Receipt + +The receipt is the point of the feature — **do not treat the call itself as proof +the data is gone**. Each store reports one of five statuses: + +| Status | Meaning | +|---|---| +| `erased` | Reached, data removed (on the vectors leg: the store accepted the delete for the ids given) | +| `not_found` | Reached, held nothing for this entity | +| `not_configured` | No such store was bound — normal, not a failure | +| `unsupported` | The store cannot delete at all; retrying will not help | +| `failed` | The store was reached and the deletion did not succeed | + +```python +receipt.to_dict() +# { +# "entity_id": "customer-4471", +# "reason": "GDPR Art. 17 request #882", +# "erased_at": "2026-08-16T09:03:36.813220", +# "complete": False, +# "stores": { +# "vectors": {"status": "unsupported", "backend": "faiss", +# "detail": "backend exposes no delete()/delete_vectors(); ..."}, +# "memory": {"status": "erased", "items": 14}, +# "graph": {"status": "erased", "nodes": 1, "edges": 3}, +# }, +# } +``` + +`complete` is `False` when any store reports `unsupported` or `failed`, which is +your signal to handle that store out of band. FAISS, Milvus and Weaviate expose +no delete method today, so erasure genuinely cannot be completed on them — the +coordinator says so rather than reporting a success it did not achieve. + +### Good to Know + +- **Order is vectors → memory → graph.** The graph tombstone is the durable record + that an erasure happened, so it is written last: a crash mid-cascade leaves the + node present and the receipt incomplete, rather than a tombstone claiming more + than actually happened. +- **A failing store does not abort the rest.** Partial failure is recorded in the + receipt and the remaining stores are still erased. +- **Every store is optional.** `ErasureCoordinator(graph=graph)` is fine; the other + legs report `not_configured`. +- **It is idempotent.** Erasing the same entity twice returns a receipt saying + there was nothing left to do, rather than raising. +- **Batch:** `coordinator.erase_entities([...], reason=...)` returns one receipt per + entity, in order, so one entity's failure does not stop the others. + +--- + ## 🔄 Using Both Together - The Complete Setup ### Your Smart Agent System diff --git a/semantica/context/erasure.py b/semantica/context/erasure.py new file mode 100644 index 00000000..49052489 --- /dev/null +++ b/semantica/context/erasure.py @@ -0,0 +1,614 @@ +""" +Cross-store erasure coordination. + +``ContextGraph.purge_node()`` is graph-scope by design (#957): it removes the +node and leaves a tombstone, but any copy of the same content held in +``AgentMemory`` or in a bound vector store is untouched. That makes purge one +step of an erasure workflow rather than the whole of it, and leaves the caller +to drive the remaining steps by hand -- with no record of which of them +actually succeeded. + +:class:`ErasureCoordinator` drives the cascade across the stores it is given +and returns an :class:`ErasureReceipt` describing what was reached and what was +not. It *composes* the existing public APIs; nothing in ``context_graph.py`` or +``agent_memory.py`` changes, and ``ContextGraph`` keeps its graph-scope +contract. + +The property that matters is honest partial reporting. Three vector backends +(FAISS, Milvus, Weaviate) expose no delete at all, so erasure is genuinely not +completable on them today. The receipt says ``unsupported`` for those rather +than reporting a success it did not achieve -- a receipt that reads +"graph: erased, memory: 14 erased, vectors: unsupported on faiss" is +actionable; a bare ``True`` is a compliance liability. + +Example: + >>> from semantica.context import ContextGraph, AgentMemory + >>> from semantica.context.erasure import ErasureCoordinator + >>> coordinator = ErasureCoordinator(graph=graph, memory=memory) + >>> receipt = coordinator.erase_entity( + ... "customer-4471", reason="GDPR Art. 17 request #882" + ... ) + >>> receipt.complete + False + >>> receipt.stores["vectors"]["status"] + 'unsupported' +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union + +from ..utils.logging import get_logger +from .context_graph import _normalize_temporal_input + +__all__ = [ + "ErasureCoordinator", + "ErasureReceipt", + "STATUS_ERASED", + "STATUS_NOT_FOUND", + "STATUS_NOT_CONFIGURED", + "STATUS_UNSUPPORTED", + "STATUS_FAILED", +] + +#: The store was reached and the entity's data removed from it. On the vectors +#: leg this means the store accepted the delete for the ids it was given: no +#: backend offers a portable "does this id exist" check, so it is not a count of +#: embeddings that were really there. The memory leg re-queries to confirm and +#: so is the stronger claim of the two. +STATUS_ERASED = "erased" +#: The store was reached and held nothing for this entity. +STATUS_NOT_FOUND = "not_found" +#: No such store was bound to the coordinator. Normal, not a failure. +STATUS_NOT_CONFIGURED = "not_configured" +#: The store exists but cannot delete -- e.g. a vector backend with no delete +#: method. Deliberately distinct from ``failed``: retrying will not help. +STATUS_UNSUPPORTED = "unsupported" +#: The store was reached and the deletion did not succeed. +STATUS_FAILED = "failed" + +#: Statuses that leave data behind. A receipt containing any of these is not +#: complete, and the shortfall has to be handled out of band. +_INCOMPLETE_STATUSES = frozenset({STATUS_UNSUPPORTED, STATUS_FAILED}) + +#: Page size for the memory sweep. See ``_erase_memory`` for why the sweep +#: loops rather than passing one large limit. +_MEMORY_SWEEP_BATCH = 500 + +logger = get_logger("erasure") + + +@dataclass +class ErasureReceipt: + """Auditable record of one entity's erasure across every bound store. + + Attributes: + entity_id: The entity the erasure was requested for. + reason: Why it was erased, e.g. an erasure-request reference. + erased_at: ISO-8601 timestamp of the erasure. + stores: Per-store outcome keyed by ``"vectors"``, ``"memory"`` and + ``"graph"``, each a dict with at least a ``status`` key drawn from + the ``STATUS_*`` constants in this module. + """ + + entity_id: str + reason: Optional[str] = None + erased_at: str = "" + stores: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + @property + def complete(self) -> bool: + """True when no bound store was left holding data. + + ``not_configured`` and ``not_found`` count as complete -- a store that + was never bound, or that held nothing, leaves no residue. Only + ``unsupported`` and ``failed`` mean data survived the erasure. + """ + return not self.incomplete_stores + + @property + def incomplete_stores(self) -> List[str]: + """Names of the stores that may still hold the entity's data.""" + return [ + name + for name, result in self.stores.items() + if result.get("status") in _INCOMPLETE_STATUSES + ] + + def to_dict(self) -> Dict[str, Any]: + """Serialize the receipt, deep-copying the per-store results.""" + return { + "entity_id": self.entity_id, + "reason": self.reason, + "erased_at": self.erased_at, + "complete": self.complete, + "stores": {name: dict(result) for name, result in self.stores.items()}, + } + + +class ErasureCoordinator: + """Drives erasure of an entity across the graph, memory and vector stores. + + Every store is optional; a store that is not supplied reports + ``not_configured`` rather than being silently skipped, so the receipt still + shows the full shape of the workflow. + + Args: + graph: A :class:`~semantica.context.ContextGraph` (or anything exposing + ``purge_node``). + memory: An :class:`~semantica.context.AgentMemory` (or anything + exposing ``find_by_entity`` and ``batch_delete``). + vector_store: Vector store holding entity-keyed embeddings. Defaults to + ``memory.vector_store`` when a memory is supplied, and stays + overridable for deployments that bind a store the memory does not + own. Pass ``False`` to disable the vector leg entirely. + + Note: + Erasure runs outward-in -- vectors, then memory, then the graph. The + graph tombstone is the durable attestation that an erasure happened, so + writing it first would let a crash mid-cascade leave a record claiming + more than actually occurred. Erasing the graph last means a partial + failure leaves the node present and the receipt incomplete, which is + recoverable and honest. + """ + + def __init__( + self, + graph: Optional[Any] = None, + memory: Optional[Any] = None, + vector_store: Optional[Any] = None, + ): + # `is None` / `is False` rather than truthiness: a real store that + # defines __bool__ or __len__ (an empty one, say) is falsey while being + # a perfectly valid store to erase from. + vector_store_given = vector_store is not None and vector_store is not False + if graph is None and memory is None and not vector_store_given: + raise ValueError( + "ErasureCoordinator needs at least one store to erase from; got " + f"graph=None, memory=None, vector_store={vector_store!r}" + ) + + self.graph = graph + self.memory = memory + if vector_store is False: + self.vector_store: Optional[Any] = None + elif vector_store is not None: + self.vector_store = vector_store + else: + self.vector_store = getattr(memory, "vector_store", None) + + self.logger = logger + + def erase_entity( + self, + entity_id: str, + reason: Optional[str] = None, + at: Optional[Union[str, int, float, datetime]] = None, + vector_ids: Optional[Sequence[str]] = None, + ) -> ErasureReceipt: + """Erase one entity from every bound store and return a receipt. + + A store that cannot be erased from is recorded in the receipt and the + cascade continues -- partial failure is a result, not an exception. + Aborting on the first failure would leave a half-erased state with no + record of which half. + + Args: + entity_id: Entity to erase. Interpreted as a graph node id, an + ``entities[].id`` in memory items, and a vector id. + reason: Why it was erased, e.g. an erasure-request reference. + Recorded in the receipt and in the graph tombstone. + at: When the erasure takes effect, used as the receipt's + ``erased_at`` and passed to ``purge_node`` so both records + carry the same instant. Accepts anything ``ContextGraph`` + accepts -- an ISO string, a ``datetime``, or epoch seconds -- + and defaults to now, UTC. + vector_ids: Explicit vector ids to remove. Defaults to + ``[entity_id]``, which covers entity-keyed embeddings written + by something other than ``AgentMemory``; vectors owned by + memory items are removed by the memory leg's own cascade. + + Returns: + An :class:`ErasureReceipt`. Check :attr:`ErasureReceipt.complete` + before treating the erasure as done. + """ + # Resolve the timestamp once and hand the *resolved* value to the graph. + # Passing the caller's `at` through instead would let purge_node take its + # own now() when `at` is None, so the receipt and the tombstone it + # attests to would disagree by however long the cascade took. + erased_at = _normalize_timestamp(at) + stores: Dict[str, Dict[str, Any]] = {} + + # Outward-in: vectors, then memory, then the graph last. + stores["vectors"] = self._erase_vectors(entity_id, vector_ids) + stores["memory"] = self._erase_memory(entity_id) + stores["graph"] = self._erase_graph(entity_id, reason, erased_at) + + receipt = ErasureReceipt( + entity_id=entity_id, + reason=reason, + erased_at=erased_at, + stores=stores, + ) + + if receipt.complete: + self.logger.info( + "Erased %r across %d store(s)%s", + entity_id, + len(stores), + f" ({reason})" if reason else "", + ) + else: + self.logger.warning( + "Erasure of %r is incomplete; these stores may still hold it: %s", + entity_id, + ", ".join(receipt.incomplete_stores), + ) + return receipt + + def erase_entities( + self, + entity_ids: Iterable[str], + reason: Optional[str] = None, + at: Optional[Union[str, int, float, datetime]] = None, + ) -> List[ErasureReceipt]: + """Erase several entities, returning one receipt per entity. + + Each entity is erased independently, so one entity's failure does not + stop the rest. Receipts come back in the order the ids were given. + """ + return [ + self.erase_entity(entity_id, reason=reason, at=at) + for entity_id in entity_ids + ] + + # Store legs + + def _erase_vectors( + self, entity_id: str, vector_ids: Optional[Sequence[str]] + ) -> Dict[str, Any]: + """Remove entity-keyed embeddings from the bound vector store. + + ``vector_ids`` in the result is the number of ids the store accepted, + not the number of embeddings that existed: backends delete by id and + report success either way, with no portable way to ask what was + actually there. See :data:`STATUS_ERASED`. + """ + if self.vector_store is None: + return {"status": STATUS_NOT_CONFIGURED} + + ids = list(vector_ids) if vector_ids is not None else [entity_id] + backend = _vector_backend_name(self.vector_store) + if not ids: + return {"status": STATUS_NOT_FOUND, "backend": backend} + + method_name, target = _vector_delete_capability(self.vector_store) + if method_name is None: + # FAISS, Milvus and Weaviate expose no delete at all; FAISS in + # particular cannot remove from a flat index without a rebuild. + self.logger.warning( + "Vector backend %r exposes no delete; %d vector id(s) for %r " + "were not erased", + backend, + len(ids), + entity_id, + ) + return { + "status": STATUS_UNSUPPORTED, + "backend": backend, + "vector_ids": len(ids), + "detail": ( + "backend exposes no delete()/delete_vectors(); " + "removal requires an index rebuild or an out-of-band process" + ), + } + + try: + deleted = getattr(target, method_name)(ids) + except NotImplementedError as exc: + # The VectorStore facade declares delete_vectors() unconditionally + # and only fails on the call when its backend cannot delete. + self.logger.warning( + "Vector backend %r cannot delete %d id(s) for %r: %s", + backend, + len(ids), + entity_id, + exc, + ) + return { + "status": STATUS_UNSUPPORTED, + "backend": backend, + "vector_ids": len(ids), + "detail": str(exc), + } + except Exception as exc: + self.logger.warning( + "Vector deletion failed for %r on backend %r: %s", + entity_id, + backend, + exc, + exc_info=True, + ) + return { + "status": STATUS_FAILED, + "backend": backend, + "vector_ids": len(ids), + "detail": f"{type(exc).__name__}: {exc}", + } + + accepted, detail = _interpret_delete_result(deleted) + result: Dict[str, Any] = { + "status": STATUS_ERASED if accepted else STATUS_FAILED, + "backend": backend, + "vector_ids": len(ids), + "via": method_name, + } + # Keep whatever the backend said. Qdrant returns {"status": ...} and + # Pinecone {"deleted": True}, and that detail is the only account of + # the delete anyone gets -- dropping it on the floor would leave the + # receipt less informative than the call it is attesting to. + if detail is not None: + result["backend_result"] = detail + if not accepted: + self.logger.warning( + "Vector backend %r reported no deletion for %r: %s", + backend, + entity_id, + detail, + ) + result["detail"] = "store reported the ids were not deleted" + return result + + def _erase_memory(self, entity_id: str) -> Dict[str, Any]: + """Delete every memory item referencing the entity.""" + if self.memory is None: + return {"status": STATUS_NOT_CONFIGURED} + + deleted = 0 + try: + # Sweep in pages until dry rather than passing one large limit: + # ``find_by_entity`` has historically defaulted to ``limit=10`` and + # truncated silently, and a single large number is only correct + # until someone exceeds it. Deleting as we go means the next page + # is the remainder. + while True: + found = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH) + if not found: + break + + memory_ids = [ + memory_id + for memory_id in (_memory_item_id(item) for item in found) + if memory_id + ] + if not memory_ids: + self.logger.warning( + "Memory returned %d item(s) for %r with no identifier; " + "cannot delete them", + len(found), + entity_id, + ) + return { + "status": STATUS_FAILED, + "items": deleted, + "residual": len(found), + "detail": "memory items carry no 'memory_id'", + } + + removed = self.memory.batch_delete(memory_ids) + deleted += removed + if removed == 0: + # No progress: another page would return the same items. + self.logger.warning( + "Memory sweep for %r stalled with %d item(s) remaining", + entity_id, + len(found), + ) + return { + "status": STATUS_FAILED, + "items": deleted, + "residual": len(found), + "detail": "batch_delete removed nothing for a non-empty page", + } + if len(found) < _MEMORY_SWEEP_BATCH: + break + + # Re-query once rather than trusting the loop's own bookkeeping; + # this is what keeps the leg's `failed` status honest. + residual = self.memory.find_by_entity(entity_id, limit=_MEMORY_SWEEP_BATCH) + except Exception as exc: + self.logger.warning( + "Memory erasure failed for %r after %d item(s): %s", + entity_id, + deleted, + exc, + exc_info=True, + ) + return { + "status": STATUS_FAILED, + "items": deleted, + "detail": f"{type(exc).__name__}: {exc}", + } + + if residual: + self.logger.warning( + "Memory still holds %d item(s) for %r after erasure", + len(residual), + entity_id, + ) + return { + "status": STATUS_FAILED, + "items": deleted, + "residual": len(residual), + "detail": "items referencing the entity survived the sweep", + } + + if deleted == 0: + return {"status": STATUS_NOT_FOUND, "items": 0} + return {"status": STATUS_ERASED, "items": deleted} + + def _erase_graph( + self, + entity_id: str, + reason: Optional[str], + at: Optional[Union[str, int, float, datetime]], + ) -> Dict[str, Any]: + """Purge the node, and with it every edge that touches it.""" + if self.graph is None: + return {"status": STATUS_NOT_CONFIGURED} + + try: + # Counted before the purge because the edges are gone afterwards. + edge_count = _incident_edge_count(self.graph, entity_id) + purged = self.graph.purge_node(entity_id, reason=reason, at=at) + except Exception as exc: + self.logger.warning( + "Graph purge failed for %r: %s", entity_id, exc, exc_info=True + ) + return { + "status": STATUS_FAILED, + "detail": f"{type(exc).__name__}: {exc}", + } + + if not purged: + return {"status": STATUS_NOT_FOUND, "nodes": 0, "edges": 0} + return {"status": STATUS_ERASED, "nodes": 1, "edges": edge_count} + + +# Helpers + + +def _normalize_timestamp(at: Optional[Union[str, int, float, datetime]]) -> str: + """Render ``at`` exactly as the graph tombstone will record it. + + Reuses ``ContextGraph``'s own normalizer rather than formatting the value + here, so the receipt and the tombstone written by the same erasure cannot + disagree about when it happened -- an audit record that contradicts the + tombstone it attests to is worse than no record. Normalizing up front also + rejects an unparseable ``at`` before any store is touched, instead of half + way through the cascade. + + ``None`` resolves to now here rather than being passed along, so the + default path gets one timestamp for both records instead of two ``now()`` + calls separated by the length of the cascade. + """ + return _normalize_temporal_input( + at if at is not None else datetime.now(timezone.utc) + ) + + +def _memory_item_id(item: Any) -> Optional[str]: + """Pull the identifier out of a memory dict as ``find_by_entity`` returns it.""" + if not isinstance(item, dict): + return None + memory_id = item.get("memory_id") or item.get("id") + return str(memory_id) if memory_id else None + + +#: Dict keys a backend uses to report whether a delete succeeded, and the +#: values that mean it did not. Qdrant returns ``{"status": }`` +#: and Pinecone ``{"deleted": True}``; neither is a bool, so a bare +#: ``result is False`` check would call every dict a success. +_DELETE_FAILURE_MARKERS = { + "deleted": (False,), + "success": (False,), + "ok": (False,), + "acknowledged": (False,), + "status": ("failed", "error", "failure"), +} + + +def _interpret_delete_result(result: Any) -> Tuple[bool, Optional[str]]: + """Decide whether a backend's delete return value reports success. + + Returns ``(accepted, detail)``, where ``detail`` is a serializable + rendering of the backend's own response to keep in the receipt (``None`` + when there was nothing worth recording). + + ``None`` counts as accepted: a delete implemented as a void method returns + it on success, and reporting ``failed`` there would be a false alarm -- + the opposite of the honesty this module is for, in the other direction. + """ + if result is None: + return True, None + if isinstance(result, bool): + return result, None + if isinstance(result, dict): + rendered = {key: _stringify(value) for key, value in result.items()} + for key, failure_values in _DELETE_FAILURE_MARKERS.items(): + if key in result and _is_failure_value(result[key], failure_values): + return False, rendered + return True, rendered + # Anything else (a count, a client response object) is taken at face value; + # there is no cross-backend contract to interpret it against. + return True, _stringify(result) + + +def _is_failure_value(value: Any, failure_values: Tuple[Any, ...]) -> bool: + """True when a backend's marker value says the delete did not happen. + + Bools are matched by identity so a ``0`` count is not read as ``False``. + String markers are matched as substrings of the rendered value, because a + backend may return an enum whose ``str()`` is ``"UpdateStatus.FAILED"`` + rather than a bare ``"failed"``. + """ + for failure in failure_values: + if isinstance(failure, bool): + if value is failure: + return True + elif failure in str(value).lower(): + return True + return False + + +def _stringify(value: Any) -> Any: + """Render a backend payload value so the receipt stays serializable. + + Qdrant's status is an enum, which would make ``to_dict()`` output + unserializable as the audit record it is meant to be. + """ + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _vector_delete_capability(store: Any) -> Tuple[Optional[str], Any]: + """Find the delete method to call, and the object to call it on. + + Returns ``(None, target)`` when no delete surface exists, which is the + ``unsupported`` case. + + The ``VectorStore`` facade declares ``delete_vectors()`` for every backend + and only raises ``NotImplementedError`` once called, so probing the facade + alone cannot tell a deletable backend from a delete-less one -- hence the + look at the backend it wraps. Probing rather than calling-and-catching also + keeps a missing method distinguishable from an ``AttributeError`` raised + *inside* a working one, which is exactly where guessing wrong would produce + a false clean bill of health. + """ + target = getattr(store, "_backend_store", None) or store + for name in ("delete_vectors", "delete"): + if callable(getattr(target, name, None)): + return name, target + return None, target + + +def _vector_backend_name(store: Any) -> str: + """Best-effort backend label for the receipt.""" + backend = getattr(store, "backend", None) + if isinstance(backend, str) and backend: + return backend + inner = getattr(store, "_backend_store", None) + return type(inner if inner is not None else store).__name__ + + +def _incident_edge_count(graph: Any, node_id: str) -> int: + """Count edges touching ``node_id`` through the graph's public API.""" + find_edges = getattr(graph, "find_edges", None) + if not callable(find_edges): + return 0 + return sum( + 1 + for edge in find_edges() + if edge.get("source") == node_id or edge.get("target") == node_id + ) diff --git a/tests/context/test_erasure_coordinator.py b/tests/context/test_erasure_coordinator.py new file mode 100644 index 00000000..04cce835 --- /dev/null +++ b/tests/context/test_erasure_coordinator.py @@ -0,0 +1,603 @@ +"""Tests for ErasureCoordinator (issue #1018). + +``ContextGraph.purge_node()`` is graph-scope by design: it removes the node and +writes a tombstone attesting the content is gone, while the same content can +survive verbatim as an ``AgentMemory`` item and as an embedding. The +coordinator drives the cascade across every bound store and returns a receipt +saying what was reached -- and, just as importantly, what was not. + +These tests run against real ``ContextGraph`` and ``AgentMemory`` instances +rather than mocks. The bug this feature exists to prevent lives in the +interaction between them (``find_by_entity`` truncating the sweep the caller +uses to decide the erasure is done), so mocking that interaction away would +test nothing. The vector stores *are* fakes, because the point of those tests +is backend shape -- ``delete_vectors`` vs ``delete`` vs neither -- and three of +the real backends cannot delete at all. +""" + +import json +import unittest + +import numpy as np + +from semantica.context import AgentMemory, ContextGraph +from semantica.context.erasure import ( + STATUS_ERASED, + STATUS_FAILED, + STATUS_NOT_CONFIGURED, + STATUS_NOT_FOUND, + STATUS_UNSUPPORTED, + ErasureCoordinator, + ErasureReceipt, +) +from semantica.vector_store import VectorStore + + +def _graph(): + """customer --purchased--> order, plus an unrelated supplier.""" + graph = ContextGraph(advanced_analytics=False) + graph.add_node("customer-4471", "person") + graph.add_node("order-9", "order") + graph.add_node("supplier-1", "org") + graph.add_edge("customer-4471", "order-9", "purchased") + return graph + + +def _memory_with(entity_id, count, extra_entity=None): + """A memory holding ``count`` items that reference ``entity_id``.""" + memory = AgentMemory() + for index in range(count): + memory.store( + f"note {index} about {entity_id}", + entities=[{"id": entity_id, "name": entity_id}], + skip_graph=True, + ) + if extra_entity: + memory.store( + f"unrelated note about {extra_entity}", + entities=[{"id": extra_entity, "name": extra_entity}], + skip_graph=True, + ) + return memory + + +class _DeleteVectorsStore: + """Backend shaped like qdrant/pinecone: exposes ``delete_vectors``.""" + + backend = "qdrant" + + def __init__(self, result=True): + self._result = result + self.deleted = [] + + def delete_vectors(self, vector_ids, **options): + self.deleted.append(list(vector_ids)) + return self._result + + +class _DeleteStore: + """Backend shaped like pgvector/sqlite-vec: exposes ``delete``.""" + + backend = "pgvector" + + def __init__(self): + self.deleted = [] + + def delete(self, ids): + self.deleted.append(list(ids)) + return True + + +class _NoDeleteStore: + """Backend shaped like FAISS/Milvus/Weaviate: no delete surface at all.""" + + backend = "faiss" + + +class _RaisingStore: + backend = "qdrant" + + def delete_vectors(self, vector_ids, **options): + raise RuntimeError("connection reset") + + +class _FacadeOverNoDeleteBackend: + """The ``VectorStore`` facade shape: declares delete_vectors for every + backend and only fails on the call, so the backend must be probed.""" + + backend = "faiss" + + def __init__(self): + self._backend_store = _NoDeleteStore() + + def delete_vectors(self, vector_ids, **options): + raise NotImplementedError("Backend store _NoDeleteStore has no delete") + + +class _MemoryVectorStore(_DeleteVectorsStore): + """Delete-capable store that AgentMemory can also write embeddings to.""" + + def store_vectors(self, vectors, metadata=None, **options): + return [f"vec-{len(self.deleted)}-{index}" for index in range(len(vectors))] + + +class TestErasureAcrossStores(unittest.TestCase): + def test_erases_graph_and_memory_and_reports_both(self): + graph, memory = _graph(), _memory_with("customer-4471", 3, "supplier-1") + receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity( + "customer-4471", reason="GDPR Art. 17 request #882" + ) + + self.assertTrue(receipt.complete) + self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["graph"]["edges"], 1) + self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["memory"]["items"], 3) + + self.assertFalse(graph.has_node("customer-4471")) + self.assertEqual(memory.find_by_entity("customer-4471", limit=500), []) + + def test_leaves_other_entities_alone(self): + graph, memory = _graph(), _memory_with("customer-4471", 2, "supplier-1") + ErasureCoordinator(graph=graph, memory=memory).erase_entity("customer-4471") + + self.assertTrue(graph.has_node("supplier-1")) + self.assertEqual(len(memory.find_by_entity("supplier-1", limit=500)), 1) + + def test_graph_purge_records_the_reason_in_its_tombstone(self): + graph = _graph() + ErasureCoordinator(graph=graph).erase_entity( + "customer-4471", reason="GDPR Art. 17 request #882" + ) + + tombstone = graph.get_tombstone("customer-4471", "node") + self.assertIsNotNone(tombstone) + self.assertEqual(tombstone["reason"], "GDPR Art. 17 request #882") + + def test_erase_entities_returns_one_receipt_per_id_in_order(self): + graph = _graph() + receipts = ErasureCoordinator(graph=graph).erase_entities( + ["customer-4471", "supplier-1", "never-existed"], reason="offboarding" + ) + + self.assertEqual( + [receipt.entity_id for receipt in receipts], + ["customer-4471", "supplier-1", "never-existed"], + ) + self.assertEqual(receipts[0].stores["graph"]["status"], STATUS_ERASED) + self.assertEqual(receipts[1].stores["graph"]["status"], STATUS_ERASED) + self.assertEqual(receipts[2].stores["graph"]["status"], STATUS_NOT_FOUND) + + +class TestMemorySweepIsNotTruncated(unittest.TestCase): + """The regression this feature exists to prevent. + + ``find_by_entity`` has historically defaulted to ``limit=10`` and truncated + silently, so the obvious hand-rolled cascade erases the first ten items and + reports success. 25 items is more than any such default, and a coordinator + that calls ``find_by_entity`` once with the default fails this test. + """ + + def test_erases_far_more_items_than_the_default_limit(self): + memory = _memory_with("customer-4471", 25) + receipt = ErasureCoordinator(memory=memory).erase_entity("customer-4471") + + self.assertEqual(receipt.stores["memory"]["items"], 25) + self.assertEqual(memory.find_by_entity("customer-4471", limit=500), []) + self.assertTrue(receipt.complete) + + def test_residual_items_are_reported_as_failed_not_erased(self): + class _UndeletableMemory: + """Deletes nothing, as a backend refusing the write would.""" + + def __init__(self): + self.items = [{"memory_id": f"m{i}"} for i in range(3)] + + def find_by_entity(self, entity_id, limit=10): + return list(self.items)[:limit] + + def batch_delete(self, memory_ids): + return 0 + + receipt = ErasureCoordinator(memory=_UndeletableMemory()).erase_entity("e1") + + self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED) + self.assertEqual(receipt.stores["memory"]["residual"], 3) + self.assertFalse(receipt.complete) + + def test_memory_items_without_an_identifier_fail_rather_than_look_erased(self): + class _AnonymousMemory: + def find_by_entity(self, entity_id, limit=10): + return [{"content": "no id here"}] + + def batch_delete(self, memory_ids): # pragma: no cover - never reached + raise AssertionError("should not delete items it cannot identify") + + receipt = ErasureCoordinator(memory=_AnonymousMemory()).erase_entity("e1") + + self.assertEqual(receipt.stores["memory"]["status"], STATUS_FAILED) + self.assertFalse(receipt.complete) + + +class TestVectorBackendShapes(unittest.TestCase): + def test_delete_vectors_backend_is_erased(self): + store = _DeleteVectorsStore() + receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["vectors"]["via"], "delete_vectors") + self.assertEqual(store.deleted, [["customer-4471"]]) + + def test_delete_backend_is_erased(self): + store = _DeleteStore() + receipt = ErasureCoordinator(vector_store=store).erase_entity("customer-4471") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["vectors"]["via"], "delete") + self.assertEqual(store.deleted, [["customer-4471"]]) + + def test_backend_without_delete_is_unsupported_not_erased(self): + receipt = ErasureCoordinator(vector_store=_NoDeleteStore()).erase_entity("e1") + + vectors = receipt.stores["vectors"] + self.assertEqual(vectors["status"], STATUS_UNSUPPORTED) + self.assertEqual(vectors["backend"], "faiss") + self.assertIn("no delete", vectors["detail"]) + self.assertFalse(receipt.complete) + + def test_facade_declaring_delete_over_a_delete_less_backend_is_unsupported(self): + receipt = ErasureCoordinator( + vector_store=_FacadeOverNoDeleteBackend() + ).erase_entity("e1") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_UNSUPPORTED) + self.assertFalse(receipt.complete) + + def test_store_reporting_no_deletion_is_failed(self): + store = _DeleteVectorsStore(result=False) + receipt = ErasureCoordinator(vector_store=store).erase_entity("e1") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED) + self.assertFalse(receipt.complete) + + def test_explicit_vector_ids_override_the_entity_id(self): + store = _DeleteVectorsStore() + ErasureCoordinator(vector_store=store).erase_entity( + "customer-4471", vector_ids=["vec-a", "vec-b"] + ) + + self.assertEqual(store.deleted, [["vec-a", "vec-b"]]) + + def test_vector_store_defaults_to_the_one_memory_holds(self): + store = _MemoryVectorStore() + memory = AgentMemory(vector_store=store) + + self.assertIs(ErasureCoordinator(memory=memory).vector_store, store) + + def test_memory_bound_vector_store_can_be_overridden(self): + owned, external = _MemoryVectorStore(), _DeleteVectorsStore() + memory = AgentMemory(vector_store=owned) + + coordinator = ErasureCoordinator(memory=memory, vector_store=external) + + self.assertIs(coordinator.vector_store, external) + + def test_vector_leg_can_be_disabled_for_a_memory_bound_store(self): + memory = AgentMemory(vector_store=_MemoryVectorStore()) + coordinator = ErasureCoordinator(memory=memory, vector_store=False) + + receipt = coordinator.erase_entity("customer-4471") + + self.assertIsNone(coordinator.vector_store) + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED) + + +class TestPartialFailureIsAResultNotAnException(unittest.TestCase): + def test_a_raising_vector_store_does_not_stop_the_remaining_legs(self): + graph, memory = _graph(), _memory_with("customer-4471", 4) + receipt = ErasureCoordinator( + graph=graph, memory=memory, vector_store=_RaisingStore() + ).erase_entity("customer-4471") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED) + self.assertIn("RuntimeError", receipt.stores["vectors"]["detail"]) + # The legs after the failure still ran. + self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED) + self.assertFalse(graph.has_node("customer-4471")) + self.assertFalse(receipt.complete) + self.assertEqual(receipt.incomplete_stores, ["vectors"]) + + def test_a_raising_graph_is_reported_after_memory_was_erased(self): + class _RaisingGraph: + def find_edges(self): + return [] + + def purge_node(self, node_id, reason=None, at=None): + raise RuntimeError("graph store unavailable") + + memory = _memory_with("customer-4471", 2) + receipt = ErasureCoordinator(graph=_RaisingGraph(), memory=memory).erase_entity( + "customer-4471" + ) + + self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["graph"]["status"], STATUS_FAILED) + self.assertFalse(receipt.complete) + + +class TestReceipt(unittest.TestCase): + def test_unconfigured_stores_are_reported_and_still_count_as_complete(self): + receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471") + + self.assertEqual(receipt.stores["memory"]["status"], STATUS_NOT_CONFIGURED) + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_NOT_CONFIGURED) + self.assertTrue(receipt.complete) + + def test_erasing_a_second_time_reports_nothing_left_rather_than_raising(self): + graph, memory = _graph(), _memory_with("customer-4471", 3) + coordinator = ErasureCoordinator(graph=graph, memory=memory) + coordinator.erase_entity("customer-4471") + + second = coordinator.erase_entity("customer-4471") + + self.assertEqual(second.stores["graph"]["status"], STATUS_NOT_FOUND) + self.assertEqual(second.stores["memory"]["status"], STATUS_NOT_FOUND) + self.assertTrue(second.complete) + + def test_to_dict_round_trips_the_reported_shape(self): + graph = _graph() + receipt = ErasureCoordinator(graph=graph).erase_entity( + "customer-4471", + reason="GDPR Art. 17 request #882", + at="2026-08-16T00:00:00Z", + ) + payload = receipt.to_dict() + + self.assertEqual(payload["entity_id"], "customer-4471") + self.assertEqual(payload["reason"], "GDPR Art. 17 request #882") + self.assertEqual(payload["erased_at"], "2026-08-16T00:00:00") + self.assertTrue(payload["complete"]) + self.assertEqual(set(payload["stores"]), {"graph", "memory", "vectors"}) + + def test_to_dict_copies_the_store_results(self): + receipt = ErasureCoordinator(graph=_graph()).erase_entity("customer-4471") + + payload = receipt.to_dict() + payload["stores"]["graph"]["status"] = "tampered" + + self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED) + + def test_receipt_and_tombstone_agree_on_when_the_erasure_happened(self): + graph = _graph() + receipt = ErasureCoordinator(graph=graph).erase_entity( + "customer-4471", at="2026-08-16T00:00:00Z" + ) + + tombstone = graph.get_tombstone("customer-4471", "node") + self.assertEqual(tombstone["purged_at"], "2026-08-16T00:00:00") + self.assertEqual(receipt.erased_at, tombstone["purged_at"]) + + def test_receipt_and_tombstone_agree_when_no_at_is_given(self): + """The default path, where the drift actually happens. + + With `at=None` the coordinator and `purge_node()` would each take their + own `now()`, so the receipt attested to a different instant than the + tombstone it points at. Passing an explicit `at` hides this, which is + why the test above passed while the common case was wrong. + """ + graph = _graph() + receipt = ErasureCoordinator(graph=graph).erase_entity("customer-4471") + + tombstone = graph.get_tombstone("customer-4471", "node") + self.assertEqual(receipt.erased_at, tombstone["purged_at"]) + + def test_epoch_seconds_are_accepted_like_the_graph_accepts_them(self): + graph = _graph() + receipt = ErasureCoordinator(graph=graph).erase_entity( + "customer-4471", at=1755302400 + ) + + tombstone = graph.get_tombstone("customer-4471", "node") + self.assertEqual(receipt.erased_at, tombstone["purged_at"]) + self.assertTrue(receipt.erased_at.startswith("2025-")) + + def test_an_unparseable_at_is_rejected_before_any_store_is_touched(self): + graph, memory = _graph(), _memory_with("customer-4471", 2) + + with self.assertRaises(ValueError): + ErasureCoordinator(graph=graph, memory=memory).erase_entity( + "customer-4471", at="not-a-timestamp" + ) + + self.assertTrue(graph.has_node("customer-4471")) + self.assertEqual(len(memory.find_by_entity("customer-4471", limit=500)), 2) + + def test_incomplete_stores_names_every_store_still_holding_data(self): + receipt = ErasureReceipt( + entity_id="e1", + stores={ + "vectors": {"status": STATUS_UNSUPPORTED}, + "memory": {"status": STATUS_FAILED}, + "graph": {"status": STATUS_ERASED}, + }, + ) + + self.assertEqual(sorted(receipt.incomplete_stores), ["memory", "vectors"]) + self.assertFalse(receipt.complete) + + +class TestRealVectorStoreBackend(unittest.TestCase): + """The fakes above assert the shapes the coordinator expects; these assert + that a real backend actually has one of them. + + This repo's recurring failure is a change verified only against the default + that reaches for internals and breaks on every other backend, so the fake + stores are worth exactly as much as the assumption that a real store looks + like them. ``VectorStore(backend="inmemory")`` is the one backend that runs + without external services, so it is the one that can hold that assumption + to account here. + """ + + def _store(self): + return VectorStore(backend="inmemory", dimension=8) + + def test_real_backend_erases_the_vector_ids_it_is_given(self): + store = self._store() + vector_ids = store.store_vectors( + vectors=[np.ones(8), np.zeros(8)], metadata=[{}, {}] + ) + self.assertEqual(store.count(), 2) + + receipt = ErasureCoordinator(vector_store=store).erase_entity( + "customer-4471", vector_ids=vector_ids + ) + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["vectors"]["backend"], "inmemory") + self.assertEqual(store.count(), 0) + + def test_the_full_cascade_removes_a_real_memory_bound_embedding(self): + """The end-to-end case the receipt actually attests to. + + Real ``ContextGraph``, real ``AgentMemory``, real ``VectorStore`` -- + the embedding is written by ``AgentMemory.store()`` and has to be gone + afterwards, which exercises the memory leg's own ``delete_memory()`` + vector cascade rather than the coordinator's model of it. + """ + store, graph = self._store(), _graph() + memory = AgentMemory(vector_store=store) + memory.store( + "note about customer-4471", + entities=[{"id": "customer-4471", "name": "customer-4471"}], + skip_graph=True, + ) + self.assertEqual(store.count(), 1) + + receipt = ErasureCoordinator(graph=graph, memory=memory).erase_entity( + "customer-4471", reason="GDPR Art. 17 request #882" + ) + + self.assertTrue(receipt.complete) + self.assertEqual(receipt.stores["memory"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["graph"]["status"], STATUS_ERASED) + self.assertEqual(store.count(), 0) + self.assertFalse(graph.has_node("customer-4471")) + self.assertEqual(memory.find_by_entity("customer-4471", limit=500), []) + + def test_erased_means_the_store_accepted_the_delete_not_that_data_existed(self): + """Pins a limit of the receipt worth knowing before trusting it. + + The in-memory backend pops the ids and returns ``True`` whether or not + they were there, and no backend offers a portable "did this id exist" + check, so the vectors leg reports how many ids the store accepted -- + not how many embeddings were really removed. ``erased`` on this leg is + therefore weaker than on the memory leg, which re-queries to confirm. + """ + store = self._store() + + receipt = ErasureCoordinator(vector_store=store).erase_entity("never-embedded") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED) + self.assertEqual(receipt.stores["vectors"]["vector_ids"], 1) + self.assertEqual(store.count(), 0) + + +class TestConstruction(unittest.TestCase): + def test_a_coordinator_with_no_stores_is_rejected(self): + with self.assertRaises(ValueError): + ErasureCoordinator() + + def test_a_single_store_is_enough(self): + self.assertIsNotNone(ErasureCoordinator(graph=_graph())) + self.assertIsNotNone(ErasureCoordinator(memory=AgentMemory())) + self.assertIsNotNone(ErasureCoordinator(vector_store=_DeleteStore())) + + def test_a_falsey_vector_store_is_still_a_store(self): + """An empty store defining __len__ is falsey but perfectly valid.""" + + class _EmptyButReal(_DeleteVectorsStore): + def __len__(self): + return 0 + + store = _EmptyButReal() + coordinator = ErasureCoordinator(vector_store=store) + + self.assertIs(coordinator.vector_store, store) + receipt = coordinator.erase_entity("customer-4471") + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_ERASED) + + +class TestBackendDeleteResults(unittest.TestCase): + """Backends report deletes as dicts, not bools. + + Qdrant returns ``{"status": }`` and Pinecone + ``{"deleted": True}``, so a bare ``result is False`` check calls every dict + a success and throws away the only account of the delete the caller gets. + """ + + def _store_returning(self, value): + store = _DeleteVectorsStore(result=value) + return store, ErasureCoordinator(vector_store=store) + + def test_qdrant_shaped_success_dict_is_erased_and_kept(self): + _, coordinator = self._store_returning({"status": "completed"}) + + vectors = coordinator.erase_entity("e1").stores["vectors"] + + self.assertEqual(vectors["status"], STATUS_ERASED) + self.assertEqual(vectors["backend_result"], {"status": "completed"}) + + def test_pinecone_shaped_success_dict_is_erased(self): + _, coordinator = self._store_returning({"deleted": True}) + + self.assertEqual( + coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED + ) + + def test_explicit_failure_marker_in_a_dict_is_failed(self): + for payload in ({"deleted": False}, {"success": False}, {"status": "failed"}): + with self.subTest(payload=payload): + _, coordinator = self._store_returning(payload) + + receipt = coordinator.erase_entity("e1") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED) + self.assertFalse(receipt.complete) + + def test_an_enum_like_failure_status_is_not_read_as_success(self): + class _UpdateStatus: + def __str__(self): + return "UpdateStatus.FAILED" + + _, coordinator = self._store_returning({"status": _UpdateStatus()}) + + receipt = coordinator.erase_entity("e1") + + self.assertEqual(receipt.stores["vectors"]["status"], STATUS_FAILED) + # Rendered as a string so the receipt stays serializable as an audit record. + self.assertEqual( + receipt.stores["vectors"]["backend_result"], + {"status": "UpdateStatus.FAILED"}, + ) + json.dumps(receipt.to_dict()) + + def test_a_zero_count_return_is_not_mistaken_for_False(self): + """`0 == False` in Python; a store reporting "0 rows" is not a failure.""" + _, coordinator = self._store_returning({"deleted": 0}) + + self.assertEqual( + coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED + ) + + def test_a_void_delete_returning_None_is_accepted(self): + """Reporting `failed` for a void method would be a false alarm.""" + _, coordinator = self._store_returning(None) + + self.assertEqual( + coordinator.erase_entity("e1").stores["vectors"]["status"], STATUS_ERASED + ) + + +if __name__ == "__main__": + unittest.main()