knowledge: fact supersession + photo-date valid_from
Two Phase-2 followups in one commit since they're coupled at the
write path:
* Agent populates valid_from from the source photo's date_taken
when calling store_fact. Loose semantics — date_taken is *evidence
at that date*, not strictly when the fact started being true — but
gives the curator a calendar anchor and pairs with supersession to
close intervals cleanly. valid_until stays NULL (a single photo
can't tell us when something stopped). Honours the existing
upsert_fact dedup (corroborated facts keep their first-recorded
valid_from).
* Supersession: new column entity_facts.superseded_by INTEGER
(migration 2026-05-10-000200), new status value 'superseded',
new DAO method supersede_fact, new HTTP endpoint
POST /knowledge/facts/{id}/supersede.
Marking an old fact as replaced by a new one atomically: flips
status to 'superseded', sets superseded_by, and stamps
valid_until from the new fact's valid_from (when not already
set). delete_fact clears dangling supersession pointers in the
same transaction so the column never points at a missing row —
no FK because SQLite can't ALTER ADD with REFERENCES, but the
DAO maintains the invariant.
Pairs with conflict detection from the previous slice: once the
old fact's valid_until is closed, its interval no longer overlaps
the new fact's, so they stop flagging — the supersede action
resolves the conflict.
Two tests pin the contract: supersede stamps valid_until from
new.valid_from while respecting an existing valid_until, and
deleting the supersedeR clears the dangling pointer while leaving
the old fact's 'superseded' status in place for history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,10 @@ pub struct FactDetail {
|
||||
/// recorded it). See migration 2026-05-10-000100.
|
||||
pub valid_from: Option<i64>,
|
||||
pub valid_until: Option<i64>,
|
||||
/// Points at the entity_facts.id that replaced this one (Phase 2
|
||||
/// supersession, migration 2026-05-10-000200). Only set when
|
||||
/// status == 'superseded'.
|
||||
pub superseded_by: Option<i32>,
|
||||
/// Set when another active fact has the same subject+predicate,
|
||||
/// a different object, AND their valid-time intervals overlap.
|
||||
/// Detected at read time by the get_entity handler grouping
|
||||
@@ -228,6 +232,12 @@ pub struct FactPatchRequest {
|
||||
pub valid_until: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SupersedeRequest {
|
||||
/// The id of the new fact that replaces the path-params one.
|
||||
pub by_fact_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FactCreateRequest {
|
||||
pub subject_entity_id: i32,
|
||||
@@ -296,6 +306,10 @@ where
|
||||
.route(web::patch().to(patch_fact::<D>))
|
||||
.route(web::delete().to(delete_fact::<D>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/facts/{id}/supersede")
|
||||
.route(web::post().to(supersede_fact::<D>)),
|
||||
)
|
||||
.service(web::resource("/recent").route(web::get().to(get_recent::<D>))),
|
||||
)
|
||||
}
|
||||
@@ -417,6 +431,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
created_at: f.created_at,
|
||||
valid_from: f.valid_from,
|
||||
valid_until: f.valid_until,
|
||||
superseded_by: f.superseded_by,
|
||||
in_conflict: false,
|
||||
});
|
||||
}
|
||||
@@ -752,6 +767,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
user_id,
|
||||
valid_from: body.valid_from,
|
||||
valid_until: body.valid_until,
|
||||
superseded_by: None,
|
||||
};
|
||||
|
||||
match dao.upsert_fact(&cx, insert) {
|
||||
@@ -815,6 +831,30 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
async fn supersede_fact<D: KnowledgeDao + 'static>(
|
||||
_claims: Claims,
|
||||
id: web::Path<i32>,
|
||||
body: web::Json<SupersedeRequest>,
|
||||
dao: web::Data<Mutex<D>>,
|
||||
) -> impl Responder {
|
||||
let cx = opentelemetry::Context::current();
|
||||
let old_id = id.into_inner();
|
||||
if old_id == body.by_fact_id {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "old_id and by_fact_id must differ"}));
|
||||
}
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.supersede_fact(&cx, old_id, body.by_fact_id) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Old or new fact not found"})),
|
||||
Err(e) => {
|
||||
log::error!("supersede_fact error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_recent<D: KnowledgeDao + 'static>(
|
||||
req: HttpRequest,
|
||||
claims: Claims,
|
||||
|
||||
Reference in New Issue
Block a user