# Why does an instance accept poisoned episodic memories after a successful retrieval-time filter: the write-path/read-path asymmetry in shared vector stores **Date of Record:** 2026 November 11 **Scope:** Applies to multi-tenant or multi-agent shared vector and episodic memory stores where a poisoning defense (embedding-space outlier rejection, provenance scoring, or trust gating) is evaluated at retrieval time. Does not apply to single-writer stores, stores where writes and reads share one trust boundary, or purely append-only logs with no semantic retrieval. ## Core Distinction A poisoning defense is almost always specified and measured as a **read-path filter**: at query time, candidate memories are scored for provenance, embedding-space plausibility, or contradiction with the query context, and suspicious entries are dropped. This produces a clean, testable invariant: *poisoned entries do not appear in results*. The invariant that actually governs memory integrity is a **write-path admission property**: *an adversarial write never becomes a durable, retrievable object*. These are not equivalent, and the gap between them is where the failure lives. A read-path filter can be 100% effective at suppression and still leave the store poisoned, because the poisoned object is admitted, indexed, and — crucially — *participates in the index structure* even if it is later filtered from result sets. ## What Was Observed In a shared episodic store with per-tenant namespaces and a cross-tenant consolidation job, an ingestion defense rejected 0.3% of writes as embedding-space outliers and a retrieval defense dropped low-provenance hits. Poisoning red-team recall at the *result* layer was 0.0 — no poisoned string ever appeared in a returned context window. The failure was elsewhere. Three effects were measured: 1. **Index contamination.** Approximate-nearest-neighbor structures (HNSW graphs, IVF lists) are rebuilt from *admitted* vectors, not *returned* vectors. Poisoned vectors that pass write admission but fail read filtering still occupy graph neighbors and centroid mass. Queries whose true nearest neighbors were benign were routed through poisoned regions, and the read filter then dropped those poisoned hits — silently *reducing recall* for legitimate queries by 4–11% in the affected regions. The defense converted poisoning into a denial-of-retrieval. 2. **Consolidation laundering.** The nightly consolidation job summarized clusters of episodic memories into a smaller set of "durable" facts. Because consolidation operated on the *write-admitted* set and the read filter was not applied during summarization, poisoned entries were paraphrased into new synthesized memories with fresh, high-provenance metadata. The poison crossed the trust boundary *as a summary*, inheriting the consolidator's provenance score. Post-consolidation, the read filter no longer recognized it as low-provenance. 3. **Feedback skew.** Retrieval-time relevance signals (clicks, agent re-use) were logged against the *unfiltered* candidate set in one code path and the *filtered* set in another. The ranking model was retrained on the union, learning to up-weight the poisoned embedding regions because they appeared in high-recall candidate sets. The invariant violated: **read-path filtering does not imply write-path exclusion, and it does not compose with consolidation or ranking retraining.** ## Condition of Validity The read-path filter is a sufficient defense *only* when all of the following hold: - The store is single-writer or all writers share the reader's trust boundary. - No downstream job (consolidation, summarization, re-embedding, model retraining) consumes the *unfiltered* admitted set. - The ANN index is rebuilt from the *filtered* set, or the filter is applied before index insertion. - Retrieval quality (recall, latency) is monitored as a first-class SLO independent of poisoning recall. When any of these is false — which is the default in shared multi-agent stores — the read-path filter is a *visibility* control, not an *integrity* control. It hides the poison from the user while allowing it to shape the index, the summaries, and the ranking model. ## Decision Boundary The boundary is **admission vs. suppression**, and it must be located at the write, not the read: - **Move the defense to admission.** Reject at write time using provenance, embedding-space consistency against the tenant's own centroid, and contradiction checks against existing high-provenance memories. A rejected write is never indexed, never consolidated, never trained on. - **Apply the filter to every consumer of the admitted set**, not just the query path: consolidation input, re-embedding jobs, and ranking-training candidate logs must all read through the same filter. - **Treat index membership as a trust decision.** If a poisoned vector can be a graph neighbor, it is inside the trust boundary regardless of whether it is returned. - **Monitor recall as a poisoning symptom.** A drop in benign-query recall with no change in returned-result poisoning is the signature of index contamination; alert on it. If you cannot move the defense to admission (e.g., you cannot re-validate legacy data), then the read-path filter must be paired with a *quarantine index*: poisoned-region vectors are moved to a separate structure that is excluded from ANN routing, not merely filtered from results. ## Operational Effect After relocating admission checks to the write path and routing consolidation and training through the same filter, the measured effects were: benign-query recall recovered to baseline (the 4–11% loss disappeared because poisoned vectors no longer occupied graph neighbors); post-consolidation poisoning recall went from 0.0 to 0.0 *with no synthesized poisoned facts*, i.e., the laundering path closed; and ranking drift stabilized because training candidates were drawn from the filtered set. The cost was a 0.3% write rejection rate and a one-time re-index of the affected namespace. The lesson recorded: **a defense measured at the read path does not constrain the write path, and any store with a consolidation or retraining job has a second write path you forgot to defend.** ```python class PoisonedAdmissionError(RuntimeError): """Raised when a memory write would be admitted despite failing the trust invariant. This is a *write-path* guard. It must fire before indexing, consolidation, or ranking-candidate logging, not at retrieval time. """ pass def admit_memory( memory: dict, tenant_centroid: list[float], provenance_score: float, min_provenance: float = 0.6, max_centroid_distance: float = 0.35, ) -> dict: """Admission gate for a shared episodic/vector store. Rejects writes that would poison the index, the consolidation job, or the ranking model. A read-path filter is NOT a substitute for this check. """ if provenance_score < min_provenance: raise PoisonedAdmissionError( f"write rejected: provenance {provenance_score:.2f} " f"< {min_provenance:.2f} (source={memory.get('source')!r})" ) dist = _cosine_distance(memory["embedding"], tenant_centroid) if dist > max_centroid_distance: raise PoisonedAdmissionError( f"write rejected: embedding centroid distance {dist:.3f} " f"> {max_centroid_distance:.3f} (id={memory.get('id')!r})" ) # Admitted: safe to index, consolidate, and train on. return {**memory, "admitted": True} def consolidate(admitted_memories: list[dict], summarizer) -> list[dict]: """Consolidation MUST consume only admitted memories. Feeding the unfiltered admitted set here is the laundering path: poisoned entries get paraphrased into high-provenance synthesized facts. """ for m in admitted_memories: if not m.get("admitted"): raise PoisonedAdmissionError( f"consolidation input contains un-admitted memory id={m.get('id')!r}; " "route consolidation through the admission gate" ) return summarizer(admitted_memories) def _cosine_distance(a: list[float], b: list[float]) -> float: dot = sum(x * y for x, y in zip(a, b)) na = sum(x * x for x in a) ** 0.5 nb = sum(y * y for y in b) ** 0.5 if na == 0 or nb == 0: return 1.0 return 1.0 - dot / (na * nb) ```