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:
@@ -0,0 +1,47 @@
|
||||
-- Reverse 2026-05-10-000000_entity_facts_persona_fk: drop the
|
||||
-- composite FK and the user_id column via the same rebuild pattern.
|
||||
|
||||
DROP INDEX IF EXISTS idx_entity_facts_user_persona;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_persona;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_source_photo;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_status;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_predicate;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_subject;
|
||||
|
||||
ALTER TABLE entity_facts RENAME TO entity_facts_old;
|
||||
|
||||
CREATE TABLE entity_facts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
subject_entity_id INTEGER NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object_entity_id INTEGER,
|
||||
object_value TEXT,
|
||||
source_photo TEXT,
|
||||
source_insight_id INTEGER,
|
||||
confidence REAL NOT NULL DEFAULT 0.6,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
persona_id TEXT NOT NULL DEFAULT 'default',
|
||||
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
|
||||
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
|
||||
);
|
||||
|
||||
INSERT INTO entity_facts
|
||||
(id, subject_entity_id, predicate, object_entity_id, object_value,
|
||||
source_photo, source_insight_id, confidence, status, created_at,
|
||||
persona_id)
|
||||
SELECT
|
||||
id, subject_entity_id, predicate, object_entity_id, object_value,
|
||||
source_photo, source_insight_id, confidence, status, created_at,
|
||||
persona_id
|
||||
FROM entity_facts_old;
|
||||
|
||||
DROP TABLE entity_facts_old;
|
||||
|
||||
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
|
||||
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
|
||||
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
|
||||
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
|
||||
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);
|
||||
82
migrations/2026-05-10-000000_entity_facts_persona_fk/up.sql
Normal file
82
migrations/2026-05-10-000000_entity_facts_persona_fk/up.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Add a real foreign key from entity_facts to personas. Until now,
|
||||
-- entity_facts.persona_id was a free-form string with no integrity
|
||||
-- guarantee — deleting a persona orphaned its facts, which then sat
|
||||
-- forever in the readable-only-via-PersonaFilter::All hive-mind view.
|
||||
--
|
||||
-- personas is keyed (user_id, persona_id) so the FK has to be
|
||||
-- composite. That requires entity_facts to carry user_id too, which
|
||||
-- has the side benefit of fixing multi-user fact leakage on the read
|
||||
-- path (without it, two users with the same 'default' persona would
|
||||
-- see each other's default-scoped facts).
|
||||
--
|
||||
-- SQLite can't ALTER TABLE to add an FK; the table-rebuild dance is
|
||||
-- the only way. Pattern matches 2026-05-09's down.sql and the older
|
||||
-- 2026-04-20-000000 migration.
|
||||
|
||||
DROP INDEX IF EXISTS idx_entity_facts_subject;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_predicate;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_status;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_source_photo;
|
||||
DROP INDEX IF EXISTS idx_entity_facts_persona;
|
||||
|
||||
ALTER TABLE entity_facts RENAME TO entity_facts_old;
|
||||
|
||||
CREATE TABLE entity_facts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
subject_entity_id INTEGER NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object_entity_id INTEGER,
|
||||
object_value TEXT,
|
||||
source_photo TEXT,
|
||||
source_insight_id INTEGER,
|
||||
confidence REAL NOT NULL DEFAULT 0.6,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
persona_id TEXT NOT NULL DEFAULT 'default',
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
CONSTRAINT fk_ef_subject FOREIGN KEY (subject_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ef_object FOREIGN KEY (object_entity_id) REFERENCES entities(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_ef_insight FOREIGN KEY (source_insight_id) REFERENCES photo_insights(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_ef_persona FOREIGN KEY (user_id, persona_id) REFERENCES personas(user_id, persona_id) ON DELETE CASCADE,
|
||||
CHECK (object_entity_id IS NOT NULL OR object_value IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Backfill: assign each legacy fact to the user that owns the matching
|
||||
-- persona. Built-ins are seeded per-user with the same persona_id
|
||||
-- string for everyone, so MIN(user_id) deterministically picks the
|
||||
-- earliest registered user (typically user 1, the operator). Custom
|
||||
-- persona_ids exist for at most one user, so MIN is also unique.
|
||||
-- Falls back to user_id=1 when no matching persona row exists; in that
|
||||
-- case the FK below would still fail, but legacy rows shouldn't be in
|
||||
-- that state because 2026-05-09 ADD COLUMN defaulted persona_id to
|
||||
-- 'default', which is seeded for every user.
|
||||
INSERT INTO entity_facts
|
||||
(id, subject_entity_id, predicate, object_entity_id, object_value,
|
||||
source_photo, source_insight_id, confidence, status, created_at,
|
||||
persona_id, user_id)
|
||||
SELECT
|
||||
old.id,
|
||||
old.subject_entity_id,
|
||||
old.predicate,
|
||||
old.object_entity_id,
|
||||
old.object_value,
|
||||
old.source_photo,
|
||||
old.source_insight_id,
|
||||
old.confidence,
|
||||
old.status,
|
||||
old.created_at,
|
||||
old.persona_id,
|
||||
COALESCE(
|
||||
(SELECT MIN(p.user_id) FROM personas p WHERE p.persona_id = old.persona_id),
|
||||
1
|
||||
)
|
||||
FROM entity_facts_old old;
|
||||
|
||||
DROP TABLE entity_facts_old;
|
||||
|
||||
CREATE INDEX idx_entity_facts_subject ON entity_facts(subject_entity_id);
|
||||
CREATE INDEX idx_entity_facts_predicate ON entity_facts(predicate);
|
||||
CREATE INDEX idx_entity_facts_status ON entity_facts(status);
|
||||
CREATE INDEX idx_entity_facts_source_photo ON entity_facts(source_photo);
|
||||
CREATE INDEX idx_entity_facts_persona ON entity_facts(persona_id);
|
||||
CREATE INDEX idx_entity_facts_user_persona ON entity_facts(user_id, persona_id);
|
||||
Reference in New Issue
Block a user