-- 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);