From 1ca3333627f780a4083008170e595b3a9dfd42de Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 23 Oct 2026 21:53:06 -0400 Subject: [PATCH] fix(owl): write a retract row when a functional value is superseded A functional-property supersede closed the prior value with an UPDATE and wrote no op=0 row, unlike an explicit retract. The change feed derives its records from rows, so it showed the new value and never the end of the old one, and a replica built from the feed kept both (local-sync-mode.md F1). The supersede now writes the same immutable op=0 row an explicit retract writes, at the superseding transaction. The resident read model reads op=1 rows and their retracted_tx stamp, so it is unaffected. The replay test asserts the replica holds exactly one current value; removing the new row turns it red. Refs aegis-xdvg.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0185RxTpLsPCQFB9ni72Nzac --- src/store/changes_replay_tests.rs | 33 +++++++++++++++++++++++++++++++ src/store/gate.rs | 22 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/store/changes_replay_tests.rs b/src/store/changes_replay_tests.rs index a1a45252b..8218f18cc 100644 --- a/src/store/changes_replay_tests.rs +++ b/src/store/changes_replay_tests.rs @@ -295,3 +295,36 @@ fn an_assert_record_reads_the_same_before_and_after_a_later_retract() { assert_eq!(before, after); assert!(after[0]["valid_to"].is_null(), "{after:#?}"); } + +/// F1: a functional-property supersede closes the old value with an UPDATE and +/// writes no retract record, so the replica keeps both values. +#[cfg(feature = "owl")] +#[test] +fn f1_functional_supersede_replays_as_one_current_value() { + const ONT: &str = "@prefix owl: .\n\ + @prefix ex: .\n\ + ex:contentHash a owl:DatatypeProperty, owl:FunctionalProperty .\n"; + let mut source = Store::open_in_memory().unwrap(); + source.owl_config_mut().validate_on_write = true; + source.load_ontology("t", ONT, TS1).unwrap(); + source.invalidate_owl_cache(); + let e = source.intern("http://example.org/doc1").unwrap(); + let a = source.intern("http://example.org/contentHash").unwrap(); + source + .transact( + &[datum(e, a, Value::Str("aaa".into()), TS1, Op::Assert)], + TS1, + None, + None, + ) + .unwrap(); + source + .transact( + &[datum(e, a, Value::Str("bbb".into()), TS2, Op::Assert)], + TS2, + None, + None, + ) + .unwrap(); + assert_replica_equals_source(&source); +} diff --git a/src/store/gate.rs b/src/store/gate.rs index 948539fbb..f03da1bd2 100644 --- a/src/store/gate.rs +++ b/src/store/gate.rs @@ -145,6 +145,14 @@ impl Store { "UPDATE facts SET valid_to = ?1, retracted_tx = ?6 \ WHERE e = ?2 AND a = ?3 AND v = ?4 AND g = ?5 AND op = 1 AND valid_to IS NULL", )?; + // The close needs the same immutable op=0 row an explicit retract + // writes. Without it the change feed shows the new value and never the + // end of the old one, so a replica built from the feed keeps both + // (local-sync-mode.md F1). + let mut retract_row = self.conn.prepare( + "INSERT INTO facts (e, a, v, g, tx, valid_from, valid_to, op) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7) ON CONFLICT(e, a, v, tx) DO NOTHING", + )?; for ((entity, attribute), values) in &proposed { // AMBIGUOUS BATCH: two distinct values for one functional property in // a single write. Superseding here would silently pick whichever the @@ -162,8 +170,20 @@ impl Store { )?.query_map(params![entity, attribute, graph], |row| row.get(0))?.collect::>()?; for bytes in existing { if crate::Value::from_bytes(&bytes)?.term_key() != *new_value { - closed += close_other + let count = close_other .execute(params![timestamp, entity, attribute, bytes, graph, tx_id])?; + if count > 0 { + retract_row.execute(params![ + entity, + attribute, + bytes, + graph, + tx_id, + timestamp, + Op::Retract as i32 + ])?; + } + closed += count; } } }