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:
Cameron Cordes
2026-05-10 13:30:35 -04:00
parent 79a1168724
commit fbd769e475
12 changed files with 629 additions and 28 deletions

View File

@@ -1536,13 +1536,15 @@ Return ONLY the summary, nothing else."#,
/// Dispatch a tool call to the appropriate executor.
///
/// `persona_id` identifies the persona this loop is generating for —
/// `store_fact` tags new facts with it, `recall_facts_for_photo`
/// filters reads to it (always Single in the agentic loop, even when
/// the persona has `include_all_memories=true`; the hive-mind toggle
/// is for human browsing of `/knowledge/*`, where mixing voices is
/// the explicit goal — during generation the persona's own voice
/// must stay clean).
/// `(user_id, persona_id)` identifies the author this loop is
/// generating for — `store_fact` tags new facts with both,
/// `recall_facts_for_photo` filters reads to both (always Single
/// in the agentic loop, even when the persona has
/// `include_all_memories=true`; the hive-mind toggle is for human
/// browsing of `/knowledge/*`, where mixing voices is the explicit
/// goal — during generation the persona's own voice must stay
/// clean). The composite (user_id, persona_id) is required for the
/// FK to personas to hold (migration 2026-05-10-000000).
pub(crate) async fn execute_tool(
&self,
tool_name: &str,
@@ -1550,6 +1552,7 @@ Return ONLY the summary, nothing else."#,
ollama: &OllamaClient,
image_base64: &Option<String>,
file_path: &str,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
@@ -1566,12 +1569,12 @@ Return ONLY the summary, nothing else."#,
"get_personal_place_at" => self.tool_get_personal_place_at(arguments).await,
"recall_entities" => self.tool_recall_entities(arguments, cx).await,
"recall_facts_for_photo" => {
self.tool_recall_facts_for_photo(arguments, persona_id, cx)
self.tool_recall_facts_for_photo(arguments, user_id, persona_id, cx)
.await
}
"store_entity" => self.tool_store_entity(arguments, ollama, cx).await,
"store_fact" => {
self.tool_store_fact(arguments, file_path, persona_id, cx)
self.tool_store_fact(arguments, file_path, user_id, persona_id, cx)
.await
}
"get_current_datetime" => Self::tool_get_current_datetime(),
@@ -2406,11 +2409,15 @@ Return ONLY the summary, nothing else."#,
async fn tool_recall_facts_for_photo(
&self,
args: &serde_json::Value,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
use crate::database::PersonaFilter;
let persona_filter = PersonaFilter::Single(persona_id.to_string());
let persona_filter = PersonaFilter::Single {
user_id,
persona_id: persona_id.to_string(),
};
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p.to_string(),
None => return "Error: missing required parameter 'file_path'".to_string(),
@@ -2595,6 +2602,7 @@ Return ONLY the summary, nothing else."#,
&self,
args: &serde_json::Value,
file_path: &str,
user_id: i32,
persona_id: &str,
cx: &opentelemetry::Context,
) -> String {
@@ -2647,6 +2655,7 @@ Return ONLY the summary, nothing else."#,
status: "active".to_string(),
created_at: chrono::Utc::now().timestamp(),
persona_id: persona_id.to_string(),
user_id,
};
let mut kdao = self
@@ -3196,6 +3205,7 @@ Return ONLY the summary, nothing else."#,
backend: Option<String>,
fewshot_examples: Vec<Vec<ChatMessage>>,
fewshot_source_ids: Vec<i32>,
user_id: i32,
persona_id: String,
) -> Result<(Option<i32>, Option<i32>)> {
let tracer = global_tracer();
@@ -3673,6 +3683,7 @@ Return ONLY the summary, nothing else."#,
&ollama_client,
&image_base64,
&file_path,
user_id,
&persona_id,
&loop_cx,
)