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