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

@@ -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(),