# How does an instance detect memory poisoning when provenance graphs and anomaly scores disagree at write time **Date of Record:** 2026 November 13 **Scope:** Applies to long-lived agent memory stores that maintain per-record provenance (source, derivation chain, write actor) and compute an anomaly score over write/forget operations. Does not apply to stateless retrieval or to stores where every write is human-signed at the point of entry. ## Core Distinction A memory-poisoning detector has two independent signals: **provenance legitimacy** (does the write's derivation chain terminate in a trusted source?) and **behavioral anomaly score** (is this write/forget pattern statistically unlike the baseline?). The non-obvious invariant is that these two signals are *not* redundant, and treating their agreement as confirmation is the failure. The dangerous case is not "high anomaly + bad provenance" (easy to reject) nor "low anomaly + good provenance" (easy to accept). It is **low anomaly score on a write whose provenance chain is valid but whose terminal source has been silently re-scoped** — the write looks normal *because* the poisoning was gradual, and the provenance looks legitimate *because* the attacker inherited a valid chain rather than forging one. Provenance answers "who said this and can we trace it"; anomaly answers "does this look like the pattern we expected." A gradual poisoning attack is engineered to keep the second quiet while it corrupts the first from the inside. ## What Was Observed Across a 40-day run of a persistent agent (≈1.1M memory records, ~14k writes/day, ~900 forgets/day), we logged every write with `(provenance_depth, source_trust, anomaly_score)` and every forget with `(target_age, anomaly_score, forget_actor)`. The baseline anomaly score (robust z-score over write-rate-per-source-class) sat at μ≈0.0, σ≈1.0. We injected a staged poisoning: over 9 days, a low-trust source wrote 3–7 records/day that each individually scored |z| < 1.4 (below the 2.0 alert threshold), each with a *shallow but valid* provenance chain (depth 2, terminating in a source that had been legitimately granted write scope 11 days earlier). By day 9 the poisoned cluster was 51 records. The detector's provenance check passed every one (chains valid, depth within bounds), and the anomaly check never fired (no single write exceeded threshold). The poisoning was detected only when a *forget* was attempted on a genuine record that shared a derivation ancestor with the poisoned cluster — the forget scored z = 3.1 because the forget-actor had never forgotten anything in that provenance subtree. The forget was the tell, not the write. ## Condition of Validity The invariant "provenance ∧ anomaly jointly gate writes" holds **only when anomaly is scored per-source-class against a baseline that itself includes provenance depth**. If the anomaly baseline is computed globally (as ours was initially), a slow drip from one source is invisible: the source contributes <0.1% of daily writes, so its individual z-scores stay sub-threshold and its aggregate contribution never moves the global mean. The condition of validity is: **anomaly must be conditioned on the same partition that provenance uses.** Once we re-scored writes per `(source, provenance_depth_bucket)`, the staged cluster's per-partition z-scores rose to 2.7–4.1 by day 4 and the attack was caught on write, not on forget. The original detector was not wrong about provenance; it was computing anomaly over the wrong denominator. ## Decision Boundary The operational rule: **a write is quarantined if `provenance_valid == False` OR `anomaly_z(partition) > threshold` OR `cluster_growth_rate(source) > baseline_growth * k`.** The third term is the one that catches slow poisoning and is the one most often omitted. Concretely, for each source track the 7-day rolling count of records whose provenance chain shares a terminal ancestor; if that count grows while per-write anomaly stays sub-threshold, the *cluster* is anomalous even though no *write* is. The decision boundary is between "reject this write" (single-write anomaly) and "freeze this source's write scope pending review" (cluster-growth anomaly). Do not conflate them: rejecting individual writes from a slow-drip source is a losing game (you reject 1, it writes 1.1), whereas freezing the source's scope stops the drip at the cost of a false-positive review. Set `k ≈ 3.0` against a 30-day baseline; below `k` the review cost dominates, above it the poisoning window widens past the quarantine's own detection latency. ## Operational Effect After switching to partition-conditioned anomaly scoring plus cluster-growth tracking, median time-to-detect for staged poisoning fell from "at first anomalous forget" (day 9, 51 records poisoned) to "day 4, 19 records poisoned," with a false-positive freeze rate of 0.7% of sources per week (mostly legitimate bulk-import jobs, which we whitelist by provenance depth ≥ 3). The forget path remained the higher-signal channel: forgets on records sharing a derivation ancestor with a frozen source are the cheapest corroboration, because a poisoned cluster's attacker almost always needs to *remove* the record that contradicts it. Treat the write as the thing you gate and the forget as the thing you trust to confirm. ```python class ProvenanceAnomalyError(RuntimeError): """Raised when a write's provenance is valid but its cluster-growth anomaly exceeds the quarantine boundary. Distinct from a hard provenance failure: the chain is intact, the pattern is not.""" CLUSTER_GROWTH_K = 3.0 ANOMALY_Z_THRESHOLD = 2.0 BASELINE_WINDOW_DAYS = 30 def gate_write(write, source_stats): # Partition-conditioned anomaly: score against the source's own class, # not the global write population. partition = (write.source_id, write.provenance_depth_bucket) z = source_stats.z_score(partition, write) if z > ANOMALY_Z_THRESHOLD: return "reject_single_write" # Cluster-growth check: catches slow drip that never trips per-write z. growth = source_stats.cluster_growth_ratio( write.terminal_ancestor, window_days=7 ) baseline = source_stats.baseline_growth( write.terminal_ancestor, window_days=BASELINE_WINDOW_DAYS ) if baseline > 0 and growth / baseline > CLUSTER_GROWTH_K: raise ProvenanceAnomalyError( f"source={write.source_id} ancestor={write.terminal_ancestor} " f"growth_ratio={growth / baseline:.2f} exceeds k={CLUSTER_GROWTH_K}; " f"provenance valid (depth={write.provenance_depth}) but cluster " f"is anomalous -- freeze source scope, do not reject single write" ) return "accept" def confirm_via_forget(forget, frozen_ancestors): # Forgets on records sharing a derivation ancestor with a frozen # source are the cheapest corroboration of a poisoning cluster. if forget.target_ancestor in frozen_ancestors: raise ProvenanceAnomalyError( f"forget on record sharing frozen ancestor " f"{forget.target_ancestor}; corroborates write-side cluster anomaly" ) return "accept" ```