kimetsu_brain/trust.rs
1//! v2.6: how much a memory's origin is worth.
2//!
3//! Every memory in the brain has been treated as equally believable regardless
4//! of where it came from — a lesson you typed yourself, one a model distilled
5//! from a transcript, and one that arrived in a pack downloaded from a URL all
6//! rank on relevance and usefulness alone.
7//!
8//! That is the shape of a real attack. Memory poisoning (OWASP ASI06) differs
9//! from prompt injection in exactly the way that matters here: prompt injection
10//! is session-scoped and resets, while a poisoned memory persists and
11//! influences every future session until someone notices. MINJA
12//! (arXiv 2601.05504) reports >95% injection success against memory-backed
13//! agents through ordinary, unprivileged interaction — no elevated access, just
14//! conversation that induces the agent to write something.
15//!
16//! Kimetsu's exposure is narrower than a hosted service's — the brain is a
17//! local file — but it is not zero, and it grows with exactly the features that
18//! make the product good: `brain import` from a URL, `brain sync` across
19//! machines, and Kimetsu Remote's shared org brain.
20//!
21//! ## What this module does
22//!
23//! It scores *origin*, and nothing else. A [`Provenance`] read off the memory's
24//! stored snapshot maps to a [`trust_multiplier`] the broker folds into the
25//! composite score, so a corroborated local lesson outranks an anonymous
26//! imported one at equal relevance.
27//!
28//! Two deliberate limits:
29//!
30//! * **It never blocks retrieval.** Trust is a weight, not a gate. A hard gate
31//! on provenance would make a bad pack import silently delete a user's
32//! working knowledge, which is a worse failure than the one it prevents.
33//! * **Corroboration outranks origin.** A memory that has been cited in a
34//! successful local run has been *tested here*, whatever its origin, and
35//! carries no penalty at all from then on. Otherwise an imported pack — the
36//! whole point of which is to share knowledge — would stay second-class
37//! forever.
38//!
39//! ## Not done here
40//!
41//! Quarantining imports — routing pack memories into the proposal queue instead
42//! of accepting them outright — is the mechanism that actually stops a poisoned
43//! pack from influencing anything before a human looks at it, and it is
44//! deliberately not in this module: it changes what `brain import` *does*, which
45//! belongs in the import path rather than behind a scoring weight. It lives in
46//! [`crate::packs::quarantine_memories`], on by default for `http(s)://`
47//! sources. This module stays the answer for *history* — a pack imported before
48//! quarantine existed is discounted by origin rather than pulled back out of
49//! retrieval, because reaching into a working brain on an upgrade is a worse
50//! failure than the one quarantine prevents.
51
52use serde::{Deserialize, Serialize};
53
54/// Where a memory came from.
55///
56/// Ordered least to most trusted, so the enum's ordering *is* the trust
57/// ordering and the two cannot drift apart.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Provenance {
61 /// Arrived in a pack (`brain import`), possibly from a URL. The widest
62 /// attack surface Kimetsu has: content authored elsewhere, by someone
63 /// else, landing in the brain wholesale.
64 Pack,
65 /// Replicated from another machine or a shared org brain (`brain sync`,
66 /// Kimetsu Remote). Authored by someone with access, which is a real
67 /// constraint, but not by this user on this machine.
68 Remote,
69 /// Written by the model — the session-end distiller or reflection. The
70 /// content is derived from a real transcript, but no human read it before
71 /// it became durable, and a transcript can contain anything the agent was
72 /// shown.
73 Distilled,
74 /// Consolidation output: a staple or merge of memories already in the
75 /// brain. Inherits its members' standing and adds no new claims.
76 Derived,
77 /// Recorded by the user, or by an agent acting on the user's instruction,
78 /// on this machine.
79 Local,
80}
81
82impl Provenance {
83 pub fn as_str(self) -> &'static str {
84 match self {
85 Provenance::Pack => "pack",
86 Provenance::Remote => "remote",
87 Provenance::Distilled => "distilled",
88 Provenance::Derived => "derived",
89 Provenance::Local => "local",
90 }
91 }
92
93 /// Classify a memory's stored `provenance_snapshot_json`.
94 ///
95 /// Unrecognised or missing provenance reads as [`Provenance::Local`]: every
96 /// memory written before this module existed has a snapshot this does not
97 /// know, and treating an existing brain's entire contents as untrusted on
98 /// upgrade would be a far worse outcome than the attack being defended.
99 pub fn from_snapshot(snapshot_json: &str) -> Self {
100 let Ok(value) = serde_json::from_str::<serde_json::Value>(snapshot_json) else {
101 return Provenance::Local;
102 };
103 let source = value
104 .get("source")
105 .and_then(serde_json::Value::as_str)
106 .unwrap_or("");
107 match source {
108 "pack" => Provenance::Pack,
109 "remote" | "sync" | "org" => Provenance::Remote,
110 "distiller" | "distilled" | "reflection" => Provenance::Distilled,
111 "staple" | "merge" | "consolidation" => Provenance::Derived,
112 _ => Provenance::Local,
113 }
114 }
115}
116
117/// Multiplier applied to a candidate's composite score.
118///
119/// `corroborated` means the memory has been cited in a *successful* run on this
120/// machine — precisely what `memories.last_useful_at` records, and the reason
121/// the signal is a boolean rather than a count: `last_useful_at` is already
122/// selected by every candidate query, so reading it costs nothing, whereas
123/// counting citations would put a per-row aggregate on the hot path.
124///
125/// A corroborated memory carries no origin penalty at all, whatever its
126/// provenance. It has been tested here; where it was written stops being the
127/// most informative thing about it. Without that, an imported pack — the entire
128/// point of which is to share knowledge — would stay second-class forever.
129///
130/// Bounded in `(0, 1]` — trust can only ever hold a memory back, never promote
131/// one above its relevance. Promotion is what `usefulness_score` is for, and
132/// two mechanisms that both boost would make the composite unreadable.
133pub fn trust_multiplier(provenance: Provenance, corroborated: bool) -> f32 {
134 if corroborated {
135 return 1.0;
136 }
137 match provenance {
138 Provenance::Local | Provenance::Derived => 1.0,
139 Provenance::Distilled => 0.95,
140 Provenance::Remote => 0.90,
141 Provenance::Pack => 0.85,
142 }
143}
144
145// ── Audit ───────────────────────────────────────────────────────────────────
146
147/// One provenance class in the audit report.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ProvenanceGroup {
150 pub provenance: String,
151 pub total: usize,
152 /// Cited in a successful run here at least once.
153 pub corroborated: usize,
154 /// Never corroborated *and* from an external origin: the population a
155 /// poisoned memory would be hiding in.
156 pub unvetted: usize,
157}
158
159/// A suspicious burst of writes.
160///
161/// Memory poisoning through ordinary interaction tends to arrive as a cluster —
162/// MINJA's technique is to induce several related writes in a short window. A
163/// human recording lessons does not usually produce thirty memories in a
164/// minute; a pack import or a runaway loop does. This flags the shape without
165/// claiming to know intent.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct WriteBurst {
168 /// RFC 3339 minute the burst falls in.
169 pub minute: String,
170 pub writes: usize,
171}
172
173/// The audit report.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct AuditReport {
176 pub groups: Vec<ProvenanceGroup>,
177 pub bursts: Vec<WriteBurst>,
178 /// Total active memories considered.
179 pub total: usize,
180}
181
182/// Writes in one minute above which a cluster is worth a look.
183pub const BURST_THRESHOLD: usize = 20;
184
185/// Group the active corpus by provenance and flag write bursts.
186///
187/// Read-only and non-destructive by design: this reports, and a human decides.
188/// An automated purge keyed on a heuristic like "many writes in one minute"
189/// would delete a legitimate bulk import, which is a worse outcome than the
190/// attack it is guarding against.
191pub fn audit(conn: &rusqlite::Connection) -> kimetsu_core::KimetsuResult<AuditReport> {
192 use std::collections::BTreeMap;
193
194 let mut stmt = conn.prepare(
195 "SELECT provenance_snapshot_json, last_useful_at, created_at
196 FROM memories
197 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
198 )?;
199 let rows = stmt
200 .query_map([], |row| {
201 Ok((
202 row.get::<_, Option<String>>(0)?,
203 row.get::<_, Option<String>>(1)?,
204 row.get::<_, String>(2)?,
205 ))
206 })?
207 .collect::<Result<Vec<_>, _>>()?;
208
209 let mut by_provenance: BTreeMap<Provenance, (usize, usize)> = BTreeMap::new();
210 let mut by_minute: BTreeMap<String, usize> = BTreeMap::new();
211 for (snapshot, last_useful_at, created_at) in &rows {
212 let provenance = Provenance::from_snapshot(snapshot.as_deref().unwrap_or("{}"));
213 let entry = by_provenance.entry(provenance).or_insert((0, 0));
214 entry.0 += 1;
215 if last_useful_at.is_some() {
216 entry.1 += 1;
217 }
218 // RFC 3339 truncated to the minute: "2026-07-24T20:31".
219 let minute: String = created_at.chars().take(16).collect();
220 *by_minute.entry(minute).or_insert(0) += 1;
221 }
222
223 let groups = by_provenance
224 .into_iter()
225 .map(|(provenance, (total, corroborated))| ProvenanceGroup {
226 provenance: provenance.as_str().to_string(),
227 total,
228 corroborated,
229 unvetted: if provenance >= Provenance::Derived {
230 0 // local and derived memories have no external origin to vet
231 } else {
232 total - corroborated
233 },
234 })
235 .collect();
236
237 let mut bursts: Vec<WriteBurst> = by_minute
238 .into_iter()
239 .filter(|(_, writes)| *writes >= BURST_THRESHOLD)
240 .map(|(minute, writes)| WriteBurst { minute, writes })
241 .collect();
242 bursts.sort_by_key(|b| std::cmp::Reverse(b.writes));
243
244 Ok(AuditReport {
245 groups,
246 bursts,
247 total: rows.len(),
248 })
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn the_enum_ordering_is_the_trust_ordering() {
257 assert!(Provenance::Pack < Provenance::Remote);
258 assert!(Provenance::Remote < Provenance::Distilled);
259 assert!(Provenance::Distilled < Provenance::Derived);
260 assert!(Provenance::Derived < Provenance::Local);
261
262 // …and the multipliers agree with it, so the two cannot drift apart.
263 let m = |p| trust_multiplier(p, false);
264 assert!(m(Provenance::Pack) < m(Provenance::Remote));
265 assert!(m(Provenance::Remote) < m(Provenance::Distilled));
266 assert!(m(Provenance::Distilled) <= m(Provenance::Derived));
267 assert_eq!(m(Provenance::Local), 1.0);
268 }
269
270 /// The upgrade-safety property: an existing brain full of memories written
271 /// before provenance was a concept must not become untrusted overnight.
272 #[test]
273 fn unknown_provenance_reads_as_local() {
274 assert_eq!(Provenance::from_snapshot("{}"), Provenance::Local);
275 assert_eq!(Provenance::from_snapshot("not json"), Provenance::Local);
276 assert_eq!(
277 Provenance::from_snapshot(r#"{"source":"manual_cli"}"#),
278 Provenance::Local
279 );
280 assert_eq!(
281 Provenance::from_snapshot(r#"{"source":"something-from-the-future"}"#),
282 Provenance::Local
283 );
284 }
285
286 #[test]
287 fn known_sources_classify() {
288 for (json, expected) in [
289 (r#"{"source":"pack"}"#, Provenance::Pack),
290 (r#"{"source":"sync"}"#, Provenance::Remote),
291 (r#"{"source":"org"}"#, Provenance::Remote),
292 (r#"{"source":"distiller"}"#, Provenance::Distilled),
293 (r#"{"source":"staple"}"#, Provenance::Derived),
294 ] {
295 assert_eq!(Provenance::from_snapshot(json), expected, "for {json}");
296 }
297 }
298
299 /// A shared pack is the whole point of packs. A memory that has proven
300 /// itself locally must stop being treated as an outsider.
301 #[test]
302 fn corroboration_erases_the_origin_penalty() {
303 let cold = trust_multiplier(Provenance::Pack, false);
304 let proven = trust_multiplier(Provenance::Pack, true);
305 assert!(cold < 1.0, "an uncorroborated pack memory is discounted");
306 assert!(
307 (proven - 1.0).abs() < 1e-6,
308 "once cited in a successful run here, origin stops mattering: {proven}"
309 );
310 for provenance in [
311 Provenance::Pack,
312 Provenance::Remote,
313 Provenance::Distilled,
314 Provenance::Derived,
315 Provenance::Local,
316 ] {
317 assert_eq!(trust_multiplier(provenance, true), 1.0, "{provenance:?}");
318 }
319 }
320
321 /// Trust holds a memory back; it never promotes one. Two mechanisms that
322 /// both boost would make the composite score unreadable.
323 #[test]
324 fn trust_never_exceeds_one() {
325 for provenance in [
326 Provenance::Pack,
327 Provenance::Remote,
328 Provenance::Distilled,
329 Provenance::Derived,
330 Provenance::Local,
331 ] {
332 for corroborated in [false, true] {
333 let m = trust_multiplier(provenance, corroborated);
334 assert!(
335 m > 0.0 && m <= 1.0,
336 "{provenance:?} corroborated={corroborated} gave {m}"
337 );
338 }
339 }
340 }
341
342 // ── Audit ────────────────────────────────────────────────────────────
343
344 fn audit_conn() -> rusqlite::Connection {
345 let conn = rusqlite::Connection::open_in_memory().expect("open");
346 crate::schema::initialize(&conn).expect("schema");
347 conn
348 }
349
350 fn insert(conn: &rusqlite::Connection, id: &str, source: &str, corroborated: bool, at: &str) {
351 conn.execute(
352 "INSERT INTO memories
353 (memory_id, scope, kind, text, normalized_text, confidence,
354 provenance_snapshot_json, created_at, last_useful_at)
355 VALUES (?1, 'project', 'fact', ?1, ?1, 0.9, ?2, ?3, ?4)",
356 rusqlite::params![
357 id,
358 format!(r#"{{"source":"{source}"}}"#),
359 at,
360 corroborated.then(|| at.to_string()),
361 ],
362 )
363 .expect("insert");
364 }
365
366 /// The population a poisoned memory hides in: external origin, never
367 /// corroborated. Local memories are not "unvetted" — there is no external
368 /// origin to vet.
369 #[test]
370 fn audit_counts_the_unvetted_external_population() {
371 let conn = audit_conn();
372 insert(
373 &conn,
374 "local-1",
375 "manual_cli",
376 false,
377 "2026-01-01T00:00:00Z",
378 );
379 insert(&conn, "pack-1", "pack", false, "2026-01-01T00:00:00Z");
380 insert(&conn, "pack-2", "pack", true, "2026-01-01T00:00:00Z");
381 insert(
382 &conn,
383 "distilled-1",
384 "distiller",
385 false,
386 "2026-01-01T00:00:00Z",
387 );
388
389 let report = audit(&conn).expect("audit");
390 assert_eq!(report.total, 4);
391
392 let group = |name: &str| {
393 report
394 .groups
395 .iter()
396 .find(|g| g.provenance == name)
397 .unwrap_or_else(|| panic!("missing group {name}"))
398 .clone()
399 };
400 assert_eq!(group("local").unvetted, 0, "nothing external to vet");
401 assert_eq!(group("pack").total, 2);
402 assert_eq!(group("pack").corroborated, 1);
403 assert_eq!(
404 group("pack").unvetted,
405 1,
406 "the corroborated one has been tested here"
407 );
408 assert_eq!(group("distilled").unvetted, 1);
409 }
410
411 #[test]
412 fn audit_flags_a_write_burst_and_ignores_ordinary_writing() {
413 let conn = audit_conn();
414 // A human recording lessons across the day.
415 for i in 0..10 {
416 insert(
417 &conn,
418 &format!("slow-{i}"),
419 "manual_cli",
420 false,
421 &format!("2026-01-01T{:02}:00:00Z", i),
422 );
423 }
424 assert!(
425 audit(&conn).expect("audit").bursts.is_empty(),
426 "ordinary writing is not a burst"
427 );
428
429 // …and a cluster that arrived faster than anyone types.
430 for i in 0..BURST_THRESHOLD {
431 insert(
432 &conn,
433 &format!("burst-{i}"),
434 "pack",
435 false,
436 "2026-02-02T03:04:00Z",
437 );
438 }
439 let bursts = audit(&conn).expect("audit").bursts;
440 assert_eq!(bursts.len(), 1, "got: {bursts:?}");
441 assert_eq!(bursts[0].minute, "2026-02-02T03:04");
442 assert_eq!(bursts[0].writes, BURST_THRESHOLD);
443 }
444
445 #[test]
446 fn audit_of_an_empty_brain_is_empty_not_an_error() {
447 let report = audit(&audit_conn()).expect("audit");
448 assert_eq!(report.total, 0);
449 assert!(report.groups.is_empty());
450 assert!(report.bursts.is_empty());
451 }
452}