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:
@@ -305,11 +305,14 @@ pub async fn get_all_insights_handler(
|
||||
#[post("/insights/generate/agentic")]
|
||||
pub async fn generate_agentic_insight_handler(
|
||||
http_request: HttpRequest,
|
||||
_claims: Claims,
|
||||
claims: Claims,
|
||||
request: web::Json<GeneratePhotoInsightRequest>,
|
||||
insight_generator: web::Data<InsightGenerator>,
|
||||
insight_dao: web::Data<std::sync::Mutex<Box<dyn InsightDao>>>,
|
||||
) -> impl Responder {
|
||||
// Service tokens (sub: "service:apollo") fall through to user_id=1
|
||||
// — the operator convention. Mobile/web clients have a numeric sub.
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
let parent_context = extract_context_from_request(&http_request);
|
||||
let tracer = global_tracer();
|
||||
let mut span = tracer.start_with_context("http.insights.generate_agentic", &parent_context);
|
||||
@@ -402,6 +405,7 @@ pub async fn generate_agentic_insight_handler(
|
||||
request.backend.clone(),
|
||||
fewshot_examples,
|
||||
fewshot_ids,
|
||||
user_id,
|
||||
persona_id,
|
||||
)
|
||||
.await;
|
||||
@@ -692,7 +696,7 @@ pub struct ChatTurnHttpResponse {
|
||||
#[post("/insights/chat")]
|
||||
pub async fn chat_turn_handler(
|
||||
http_request: HttpRequest,
|
||||
_claims: Claims,
|
||||
claims: Claims,
|
||||
request: web::Json<ChatTurnHttpRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
@@ -711,8 +715,14 @@ pub async fn chat_turn_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Service-token claims (sub: "service:apollo") fall through to
|
||||
// user_id=1 — the operator convention. Mobile/web clients have a
|
||||
// numeric sub. Required for the entity_facts composite FK.
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
|
||||
let chat_req = ChatTurnRequest {
|
||||
library_id: library.id,
|
||||
user_id,
|
||||
file_path: request.file_path.clone(),
|
||||
user_message: request.user_message.clone(),
|
||||
model: request.model.clone(),
|
||||
@@ -914,7 +924,7 @@ pub async fn chat_history_handler(
|
||||
/// Returns `text/event-stream` with one event per chat stream event.
|
||||
#[post("/insights/chat/stream")]
|
||||
pub async fn chat_stream_handler(
|
||||
_claims: Claims,
|
||||
claims: Claims,
|
||||
request: web::Json<ChatTurnHttpRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
@@ -928,8 +938,12 @@ pub async fn chat_stream_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Service-token sub falls through to user_id=1 (see chat_turn_handler).
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
|
||||
let chat_req = ChatTurnRequest {
|
||||
library_id: library.id,
|
||||
user_id,
|
||||
file_path: request.file_path.clone(),
|
||||
user_message: request.user_message.clone(),
|
||||
model: request.model.clone(),
|
||||
|
||||
@@ -31,6 +31,12 @@ pub type ChatLockMap = Arc<TokioMutex<HashMap<(i32, String), Arc<TokioMutex<()>>
|
||||
#[derive(Debug)]
|
||||
pub struct ChatTurnRequest {
|
||||
pub library_id: i32,
|
||||
/// Author's user_id, extracted from Claims at the handler. Tagged
|
||||
/// onto every entity_fact row written this turn so the composite FK
|
||||
/// (user_id, persona_id) → personas holds and so cross-user reads
|
||||
/// stay isolated. Service token claims that don't parse as i32
|
||||
/// fall through to user_id=1 (operator convention).
|
||||
pub user_id: i32,
|
||||
pub file_path: String,
|
||||
pub user_message: String,
|
||||
/// Override the model id. Local mode: an Ollama model name. Hybrid:
|
||||
@@ -475,6 +481,7 @@ impl InsightChatService {
|
||||
&ollama_client,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
req.user_id,
|
||||
&active_persona,
|
||||
&loop_cx,
|
||||
)
|
||||
@@ -843,6 +850,7 @@ impl InsightChatService {
|
||||
tools,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
req.user_id,
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
@@ -1031,6 +1039,7 @@ impl InsightChatService {
|
||||
tools,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
req.user_id,
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
@@ -1181,6 +1190,7 @@ impl InsightChatService {
|
||||
tools: Vec<Tool>,
|
||||
image_base64: &Option<String>,
|
||||
normalized: &str,
|
||||
user_id: i32,
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
@@ -1260,6 +1270,7 @@ impl InsightChatService {
|
||||
ollama_client,
|
||||
image_base64,
|
||||
normalized,
|
||||
user_id,
|
||||
active_persona,
|
||||
&cx,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user