diff --git a/migrations/2026-05-10-000200_entity_facts_superseded_by/down.sql b/migrations/2026-05-10-000200_entity_facts_superseded_by/down.sql
new file mode 100644
index 0000000..1e09629
--- /dev/null
+++ b/migrations/2026-05-10-000200_entity_facts_superseded_by/down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_entity_facts_superseded_by;
+ALTER TABLE entity_facts DROP COLUMN superseded_by;
diff --git a/migrations/2026-05-10-000200_entity_facts_superseded_by/up.sql b/migrations/2026-05-10-000200_entity_facts_superseded_by/up.sql
new file mode 100644
index 0000000..be04ae0
--- /dev/null
+++ b/migrations/2026-05-10-000200_entity_facts_superseded_by/up.sql
@@ -0,0 +1,31 @@
+-- Add a supersession pointer to entity_facts.
+--
+-- Status alone is a one-way trapdoor: 'rejected' loses the link
+-- between the rejected fact and the one that replaced it. For
+-- evolving facts (Cameron's relationship, employer, address) the
+-- curator wants to *replace* a stale fact with a new one and keep
+-- the history readable: "from 2018 until 2022 this was true, then
+-- it became this other thing".
+--
+-- A nullable INTEGER column pointing at another entity_facts.id —
+-- no FK constraint because SQLite can't ALTER ADD COLUMN with REFs;
+-- the DAO's delete_fact clears dangling pointers in the same
+-- transaction as the parent delete to keep the column honest.
+--
+-- A status of 'superseded' on the old fact (alongside the existing
+-- active / reviewed / rejected) signals "replaced by a newer
+-- claim". Read paths already filter 'rejected' out of the active
+-- view; the curation UI will treat 'superseded' the same way for
+-- conflict detection so they don't keep flagging.
+--
+-- Pairs with the valid-time columns from 2026-05-10-000100: the
+-- supersede action auto-stamps the old fact's `valid_until` from
+-- the new fact's `valid_from`, closing the interval cleanly.
+
+ALTER TABLE entity_facts ADD COLUMN superseded_by INTEGER;
+
+-- Helpful index for "show me what superseded this fact" walks
+-- (rare today; cheap to add now while the table is small).
+CREATE INDEX idx_entity_facts_superseded_by
+ ON entity_facts(superseded_by)
+ WHERE superseded_by IS NOT NULL;
diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs
index 1f9dfe7..60bc50c 100644
--- a/src/ai/insight_generator.rs
+++ b/src/ai/insight_generator.rs
@@ -2672,6 +2672,19 @@ Return ONLY the summary, nothing else."#,
file_path
);
+ // Anchor the fact in valid-time using the source photo's
+ // `date_taken` (Apollo's naive-as-UTC convention is fine
+ // here — we only care about calendar ordering, not absolute
+ // UTC). The semantic stretch: a photo *evidences* the fact at
+ // that date — the fact may have started earlier — so this is
+ // best read as "no later than this it started being true",
+ // not a strict lower bound. Still useful: gives the curator a
+ // calendar anchor and lets supersession (next slice) close
+ // intervals cleanly when a newer fact arrives. valid_until
+ // stays NULL — a single photo can't tell us when something
+ // *stopped* being true.
+ let valid_from = self.fetch_exif(file_path).and_then(|e| e.date_taken);
+
let fact = InsertEntityFact {
subject_entity_id,
predicate,
@@ -2684,11 +2697,9 @@ Return ONLY the summary, nothing else."#,
created_at: chrono::Utc::now().timestamp(),
persona_id: persona_id.to_string(),
user_id,
- // The agentic loop doesn't yet derive valid-time from the
- // photo's date_taken. Left NULL for now; Phase 2's
- // supersession + a future agent tool will populate these.
- valid_from: None,
+ valid_from,
valid_until: None,
+ superseded_by: None,
};
let mut kdao = self
diff --git a/src/database/knowledge_dao.rs b/src/database/knowledge_dao.rs
index cd6a9bb..57518fe 100644
--- a/src/database/knowledge_dao.rs
+++ b/src/database/knowledge_dao.rs
@@ -226,6 +226,21 @@ pub trait KnowledgeDao: Sync + Send {
fn delete_fact(&mut self, cx: &opentelemetry::Context, id: i32) -> Result<(), DbError>;
+ /// Mark an old fact as superseded by a new one. Atomically:
+ /// - reads the new fact's valid_from
+ /// - sets old.superseded_by = new_id
+ /// - sets old.status = 'superseded'
+ /// - stamps old.valid_until = new.valid_from (if not already
+ /// set; otherwise leaves it)
+ ///
+ /// Returns the updated old fact. Errors if either id is missing.
+ fn supersede_fact(
+ &mut self,
+ cx: &opentelemetry::Context,
+ old_id: i32,
+ new_id: i32,
+ ) -> Result