personas: composite FK + built-in update guard
Two persona-infrastructure correctness fixes that go together because
the second one (FK with CASCADE) requires the first (preventing the
persona row from being mutated out from under its facts).
1. update_persona handler refuses name/systemPrompt edits to built-ins
(409). includeAllMemories stays editable — that's a per-user
preference, not the persona's identity. Mirrors the existing
delete_persona guard. The DAO is intentionally permissive so the
guard sits at the HTTP layer; persona_dao test pins that contract.
2. Migration 2026-05-10 adds user_id to entity_facts and a composite
FK (user_id, persona_id) -> personas(user_id, persona_id) ON DELETE
CASCADE. This closes two issues at once:
- Persona orphans: deleting a custom persona used to leave its
facts dangling forever, readable only via PersonaFilter::All.
CASCADE now wipes them with the persona row.
- Multi-user fact leakage: PersonaFilter::Single("default") used
to surface every user's default-scoped facts. PersonaFilter is
now { user_id, persona_id } and all read paths
(get_facts_for_entity, list_facts, get_recent_activity) filter
on user_id first. upsert_fact's dedup key extends to user_id so
identical claims under shared persona names from different
users no longer corroborate-bump each other's confidence.
- user_id threads from Claims.sub.parse::<i32>().unwrap_or(1) at
the chat / insight handlers through ChatTurnRequest, the
streaming agentic loop, execute_tool, and into the leaf tools
(tool_store_fact, tool_recall_facts_for_photo). The ".unwrap_or(1)"
accommodates Apollo's service token whose sub is non-numeric on
legacy mints.
- Backfill picks the smallest user_id matching each legacy fact's
persona_id so the FK holds for already-stored rows.
Five new knowledge_dao tests with FK-on connection: persona scoping
isolation, All-variant union per-user, dedup not crossing users,
CASCADE delete, FK rejection of unknown personas. Plus
dao_update_does_not_block_built_ins documenting where the
HTTP-layer guard lives.
Apollo coordinates separately — the matching changes there add the
/api/personas proxy and start sending persona_id on photo-chat turns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,12 +57,25 @@ pub struct FactFilter {
|
||||
|
||||
/// Persona scoping for fact reads. `Single` filters to one persona's
|
||||
/// view; `All` is the hive-mind read used when a persona has
|
||||
/// `include_all_memories=true` in the personas table. Entities and
|
||||
/// photo-links are always shared and don't take a persona filter.
|
||||
/// `include_all_memories=true` in the personas table. Both variants
|
||||
/// carry `user_id` because facts are user-isolated — two users with
|
||||
/// the same 'default' persona must not see each other's facts (this
|
||||
/// is enforced at the schema level by the composite FK in migration
|
||||
/// 2026-05-10). Entities and photo-links are always shared and don't
|
||||
/// take a persona filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonaFilter {
|
||||
Single(String),
|
||||
All,
|
||||
Single { user_id: i32, persona_id: String },
|
||||
All { user_id: i32 },
|
||||
}
|
||||
|
||||
impl PersonaFilter {
|
||||
pub fn user_id(&self) -> i32 {
|
||||
match self {
|
||||
Self::Single { user_id, .. } => *user_id,
|
||||
Self::All { user_id } => *user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntityPatch {
|
||||
@@ -598,12 +611,14 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
|
||||
// Look for an identical active fact AUTHORED BY THE SAME
|
||||
// PERSONA. The same claim from a different persona is a
|
||||
// (USER, PERSONA). The same claim from a different persona —
|
||||
// or from a different user with the same persona name — is a
|
||||
// separate fact (each persona's voice/confidence is its own),
|
||||
// not a confidence bump on someone else's row.
|
||||
let mut dup_query = entity_facts
|
||||
.filter(subject_entity_id.eq(fact.subject_entity_id))
|
||||
.filter(predicate.eq(&fact.predicate))
|
||||
.filter(user_id.eq(fact.user_id))
|
||||
.filter(persona_id.eq(&fact.persona_id))
|
||||
.filter(status.ne("rejected"))
|
||||
.into_boxed();
|
||||
@@ -665,8 +680,9 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
let mut q = entity_facts
|
||||
.filter(subject_entity_id.eq(entity_id))
|
||||
.filter(status.ne("rejected"))
|
||||
.filter(user_id.eq(persona.user_id()))
|
||||
.into_boxed();
|
||||
if let PersonaFilter::Single(pid) = persona {
|
||||
if let PersonaFilter::Single { persona_id: pid, .. } = persona {
|
||||
q = q.filter(persona_id.eq(pid.clone()));
|
||||
}
|
||||
q.load::<EntityFact>(conn.deref_mut())
|
||||
@@ -688,6 +704,11 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
let mut query = entity_facts.into_boxed();
|
||||
let mut count_query = entity_facts.into_boxed();
|
||||
|
||||
// user_id always applies — facts are user-isolated.
|
||||
let uid = filter.persona.user_id();
|
||||
query = query.filter(user_id.eq(uid));
|
||||
count_query = count_query.filter(user_id.eq(uid));
|
||||
|
||||
if let Some(eid) = filter.entity_id {
|
||||
query = query.filter(subject_entity_id.eq(eid));
|
||||
count_query = count_query.filter(subject_entity_id.eq(eid));
|
||||
@@ -701,7 +722,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
query = query.filter(predicate.eq(pred));
|
||||
count_query = count_query.filter(predicate.eq(pred));
|
||||
}
|
||||
if let PersonaFilter::Single(ref pid) = filter.persona {
|
||||
if let PersonaFilter::Single { persona_id: ref pid, .. } = filter.persona {
|
||||
query = query.filter(persona_id.eq(pid.clone()));
|
||||
count_query = count_query.filter(persona_id.eq(pid.clone()));
|
||||
}
|
||||
@@ -901,8 +922,9 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
|
||||
let mut facts_q = ef::entity_facts
|
||||
.filter(ef::created_at.gt(since))
|
||||
.filter(ef::user_id.eq(persona.user_id()))
|
||||
.into_boxed();
|
||||
if let PersonaFilter::Single(pid) = persona {
|
||||
if let PersonaFilter::Single { persona_id: pid, .. } = persona {
|
||||
facts_q = facts_q.filter(ef::persona_id.eq(pid.clone()));
|
||||
}
|
||||
let recent_facts = facts_q
|
||||
@@ -919,3 +941,339 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Persona scoping + composite-FK invariants for entity_facts.
|
||||
//!
|
||||
//! These tests pin three contracts that are silently regressable:
|
||||
//!
|
||||
//! 1. PersonaFilter::Single isolates per (user_id, persona_id). Two
|
||||
//! users with the same 'default' persona must not see each
|
||||
//! other's facts (multi-user leakage was a latent bug before
|
||||
//! migration 2026-05-10 added user_id + composite FK).
|
||||
//!
|
||||
//! 2. PersonaFilter::All scopes to a single user but unions across
|
||||
//! that user's personas. Hive-mind for human browsing of
|
||||
//! /knowledge/*; never crosses users.
|
||||
//!
|
||||
//! 3. Deleting a persona CASCADEs to the user's facts under that
|
||||
//! persona — and ONLY that user's, ONLY that persona's. Other
|
||||
//! users sharing the persona_id name keep their facts.
|
||||
//!
|
||||
//! FKs aren't enabled by default on Diesel's SQLite connection;
|
||||
//! `connection_with_fks_on()` flips the pragma so the cascade
|
||||
//! actually fires in tests (mirroring runtime in production).
|
||||
|
||||
use super::*;
|
||||
use crate::database::models::{InsertEntity, InsertEntityFact, InsertPersona};
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
use diesel::connection::SimpleConnection;
|
||||
|
||||
fn connection_with_fks_on() -> Arc<Mutex<SqliteConnection>> {
|
||||
let mut conn = in_memory_db_connection();
|
||||
conn.batch_execute("PRAGMA foreign_keys = ON;")
|
||||
.expect("enable foreign_keys pragma");
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
fn create_user(conn: &Arc<Mutex<SqliteConnection>>, username: &str) -> i32 {
|
||||
use crate::database::schema::users::dsl as u;
|
||||
let mut c = conn.lock().unwrap();
|
||||
diesel::insert_into(u::users)
|
||||
.values((u::username.eq(username), u::password.eq("x")))
|
||||
.execute(c.deref_mut())
|
||||
.unwrap();
|
||||
u::users
|
||||
.filter(u::username.eq(username))
|
||||
.select(u::id)
|
||||
.first(c.deref_mut())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn create_persona_row(conn: &Arc<Mutex<SqliteConnection>>, uid: i32, pid: &str) {
|
||||
use crate::database::schema::personas::dsl as p;
|
||||
let mut c = conn.lock().unwrap();
|
||||
diesel::insert_into(p::personas)
|
||||
.values(InsertPersona {
|
||||
user_id: uid,
|
||||
persona_id: pid,
|
||||
name: pid,
|
||||
system_prompt: "test prompt",
|
||||
is_built_in: false,
|
||||
include_all_memories: false,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
})
|
||||
.execute(c.deref_mut())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_entity(dao: &mut SqliteKnowledgeDao, name: &str) -> Entity {
|
||||
let cx = opentelemetry::Context::new();
|
||||
dao.upsert_entity(
|
||||
&cx,
|
||||
InsertEntity {
|
||||
name: name.to_string(),
|
||||
entity_type: "person".to_string(),
|
||||
description: String::new(),
|
||||
embedding: None,
|
||||
confidence: 0.6,
|
||||
status: "active".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn add_fact(
|
||||
dao: &mut SqliteKnowledgeDao,
|
||||
subject: i32,
|
||||
predicate: &str,
|
||||
value: &str,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
) -> EntityFact {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (fact, _) = dao
|
||||
.upsert_fact(
|
||||
&cx,
|
||||
InsertEntityFact {
|
||||
subject_entity_id: subject,
|
||||
predicate: predicate.to_string(),
|
||||
object_entity_id: None,
|
||||
object_value: Some(value.to_string()),
|
||||
source_photo: None,
|
||||
source_insight_id: None,
|
||||
confidence: 0.6,
|
||||
status: "active".to_string(),
|
||||
created_at: 0,
|
||||
persona_id: persona_id.to_string(),
|
||||
user_id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
fact
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_filter_single_isolates_per_user() {
|
||||
// Two users, same persona name. Each user's facts under that
|
||||
// persona must NOT surface to the other user's reads — this is
|
||||
// the multi-user leakage that motivated adding user_id.
|
||||
let cx = opentelemetry::Context::new();
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
let bob = create_user(&conn, "bob");
|
||||
create_persona_row(&conn, alice, "default");
|
||||
create_persona_row(&conn, bob, "default");
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let entity = make_entity(&mut dao, "Cabin");
|
||||
|
||||
add_fact(&mut dao, entity.id, "located_in", "Vermont", alice, "default");
|
||||
add_fact(&mut dao, entity.id, "color", "red", bob, "default");
|
||||
|
||||
let alice_view = dao
|
||||
.get_facts_for_entity(
|
||||
&cx,
|
||||
entity.id,
|
||||
&PersonaFilter::Single {
|
||||
user_id: alice,
|
||||
persona_id: "default".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(alice_view.len(), 1);
|
||||
assert_eq!(alice_view[0].predicate, "located_in");
|
||||
|
||||
let bob_view = dao
|
||||
.get_facts_for_entity(
|
||||
&cx,
|
||||
entity.id,
|
||||
&PersonaFilter::Single {
|
||||
user_id: bob,
|
||||
persona_id: "default".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bob_view.len(), 1);
|
||||
assert_eq!(bob_view[0].predicate, "color");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_filter_all_unions_across_personas_one_user() {
|
||||
// include_all_memories=true → All variant: see this user's
|
||||
// facts across all their personas. Must NOT include other
|
||||
// users' facts even when they share a persona name.
|
||||
let cx = opentelemetry::Context::new();
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
let bob = create_user(&conn, "bob");
|
||||
create_persona_row(&conn, alice, "default");
|
||||
create_persona_row(&conn, alice, "journal");
|
||||
create_persona_row(&conn, bob, "default");
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let entity = make_entity(&mut dao, "Cabin");
|
||||
|
||||
add_fact(&mut dao, entity.id, "p1", "v1", alice, "default");
|
||||
add_fact(&mut dao, entity.id, "p2", "v2", alice, "journal");
|
||||
add_fact(&mut dao, entity.id, "p3", "v3", bob, "default");
|
||||
|
||||
let alice_all = dao
|
||||
.get_facts_for_entity(&cx, entity.id, &PersonaFilter::All { user_id: alice })
|
||||
.unwrap();
|
||||
let predicates: Vec<&str> = alice_all.iter().map(|f| f.predicate.as_str()).collect();
|
||||
assert_eq!(predicates.len(), 2);
|
||||
assert!(predicates.contains(&"p1"));
|
||||
assert!(predicates.contains(&"p2"));
|
||||
assert!(
|
||||
!predicates.contains(&"p3"),
|
||||
"All variant must not leak across users"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_fact_dedup_does_not_cross_users() {
|
||||
// Two users insert the SAME claim (same subject + predicate +
|
||||
// object_value) under the same persona name. Pre-fix, the
|
||||
// dedup key was (subject, predicate, persona_id) and bob's
|
||||
// insert would corroborate alice's row instead of creating a
|
||||
// new one. Post-fix the key includes user_id, so each user
|
||||
// gets their own row at confidence=0.6.
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
let bob = create_user(&conn, "bob");
|
||||
create_persona_row(&conn, alice, "default");
|
||||
create_persona_row(&conn, bob, "default");
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let entity = make_entity(&mut dao, "Cabin");
|
||||
|
||||
let alice_fact = add_fact(&mut dao, entity.id, "color", "red", alice, "default");
|
||||
let bob_fact = add_fact(&mut dao, entity.id, "color", "red", bob, "default");
|
||||
|
||||
assert_ne!(alice_fact.id, bob_fact.id, "must be separate rows");
|
||||
assert_eq!(alice_fact.confidence, 0.6);
|
||||
assert_eq!(
|
||||
bob_fact.confidence, 0.6,
|
||||
"bob's row should not have been corroboration-bumped against alice's"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_persona_cascades_only_that_users_facts() {
|
||||
// Composite FK + CASCADE: deleting alice's 'journal' persona
|
||||
// wipes alice's journal facts but leaves alice's default
|
||||
// facts AND bob's journal-named facts untouched.
|
||||
let cx = opentelemetry::Context::new();
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
let bob = create_user(&conn, "bob");
|
||||
create_persona_row(&conn, alice, "default");
|
||||
create_persona_row(&conn, alice, "journal");
|
||||
create_persona_row(&conn, bob, "journal");
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let entity = make_entity(&mut dao, "Cabin");
|
||||
|
||||
add_fact(&mut dao, entity.id, "p_alice_default", "x", alice, "default");
|
||||
add_fact(&mut dao, entity.id, "p_alice_journal", "y", alice, "journal");
|
||||
add_fact(&mut dao, entity.id, "p_bob_journal", "z", bob, "journal");
|
||||
|
||||
// Delete alice's journal persona — CASCADE should remove only
|
||||
// alice's journal facts.
|
||||
{
|
||||
use crate::database::schema::personas::dsl as p;
|
||||
let mut c = conn.lock().unwrap();
|
||||
diesel::delete(
|
||||
p::personas
|
||||
.filter(p::user_id.eq(alice))
|
||||
.filter(p::persona_id.eq("journal")),
|
||||
)
|
||||
.execute(c.deref_mut())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// alice/default survives.
|
||||
let alice_default = dao
|
||||
.get_facts_for_entity(
|
||||
&cx,
|
||||
entity.id,
|
||||
&PersonaFilter::Single {
|
||||
user_id: alice,
|
||||
persona_id: "default".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(alice_default.len(), 1);
|
||||
assert_eq!(alice_default[0].predicate, "p_alice_default");
|
||||
|
||||
// alice/journal is gone.
|
||||
let alice_journal = dao
|
||||
.get_facts_for_entity(
|
||||
&cx,
|
||||
entity.id,
|
||||
&PersonaFilter::Single {
|
||||
user_id: alice,
|
||||
persona_id: "journal".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
alice_journal.is_empty(),
|
||||
"CASCADE should have removed alice's journal facts"
|
||||
);
|
||||
|
||||
// bob/journal — same persona name, different user — untouched.
|
||||
let bob_journal = dao
|
||||
.get_facts_for_entity(
|
||||
&cx,
|
||||
entity.id,
|
||||
&PersonaFilter::Single {
|
||||
user_id: bob,
|
||||
persona_id: "journal".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bob_journal.len(), 1);
|
||||
assert_eq!(bob_journal[0].predicate, "p_bob_journal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fact_insert_with_unknown_persona_is_rejected() {
|
||||
// FK enforcement: inserting a fact whose (user_id, persona_id)
|
||||
// pair has no matching personas row should fail. Protects
|
||||
// against typo'd persona ids silently leaking into the table.
|
||||
let cx = opentelemetry::Context::new();
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
// Note: NO persona row inserted for alice + 'ghost'.
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let entity = make_entity(&mut dao, "Cabin");
|
||||
|
||||
let result = dao.upsert_fact(
|
||||
&cx,
|
||||
InsertEntityFact {
|
||||
subject_entity_id: entity.id,
|
||||
predicate: "color".to_string(),
|
||||
object_entity_id: None,
|
||||
object_value: Some("red".to_string()),
|
||||
source_photo: None,
|
||||
source_insight_id: None,
|
||||
confidence: 0.6,
|
||||
status: "active".to_string(),
|
||||
created_at: 0,
|
||||
persona_id: "ghost".to_string(),
|
||||
user_id: alice,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"FK should reject fact whose persona doesn't exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user