Skip to content

Commit 60dc132

Browse files
tcconnallyclaude
andcommitted
fix(temporal): audit set_valid_to closes with an entity_history snapshot (#373)
set_valid_to enforced tighten-only semantics but wrote no entity_history snapshot, so a close was invisible to transaction-time reconstruction: as_of/bitemporal_at at a tx instant BEFORE the close reported the close anyway (the live row's valid_to was the only record and unversioned), and the #372 audited-re-assert snapshot then conflated the close into the whole pre-extension recorded window. Mirror the #371/#372 audited stamp in set_valid_to: an effective close/tighten snapshots the pre-change row (invalidated = now with the anti-zero-width bump, superseded_by = self), advances the live row's recorded_at, links supersedes, and pins a still-NULL valid_from to the old effective opening so pre-v9 rows don't shift. The tighten-only guards are untouched; a no-op call (earlier stored close kept) writes no snapshot. mimir_supersede funnels through set_valid_to and inherits the audit for free. The verbatim snapshot INSERT shared with remember_impl is factored into snapshot_live_row_to_history. Tests: set_valid_to_close_is_audited_and_noop_is_not (exactly one snapshot; bitemporal_at at a pre-close tx instant reconstructs the fact OPEN, current knowledge shows the close; same-value and would-extend no-ops add nothing) and supersede_close_inherits_the_audit_snapshot. 254 passed, 2 ignored; temporal gate 100% (20/20). Closes #373 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a7db84 commit 60dc132

3 files changed

Lines changed: 215 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ All notable changes to Perseus Vault (formerly Mimir/Mneme) are documented here.
4646
count 53 → 55.
4747

4848
### Fixed
49+
- Audited `set_valid_to` closes (#373): closing/tightening a fact's valid
50+
period (directly or via `mimir_supersede`) now snapshots the pre-close
51+
version to `entity_history` and advances the live row's transaction time —
52+
previously a close was invisible to `mimir_as_of`/`mimir_bitemporal`
53+
reconstruction, which reported the close even at transaction instants
54+
before it happened. Tighten-only acceptance semantics are unchanged, and a
55+
no-op call (an earlier stored close is kept) writes no snapshot.
4956
- Bi-temporal audit gap (#371): an identical-body re-remember that moves the
5057
bounds of an already-CLOSED valid period (e.g. re-extending past a
5158
`mimir_supersede`/`set_valid_to` close) now snapshots the pre-change version

src/db.rs

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,23 +1848,7 @@ impl Database {
18481848
// re-assert (#371) — same body, but the pre-change valid period
18491849
// must stay reconstructable.
18501850
if content_changed || audit_period_change {
1851-
tx.execute(
1852-
"INSERT INTO entity_history
1853-
(history_id, id, category, key, body_json, status, type, tags,
1854-
decay_score, retrieval_count, layer, topic_path, archived,
1855-
archive_reason, links, verified, source, always_on, certainty,
1856-
workspace_hash, agent_id, visibility, valid_from_unix_ms,
1857-
valid_to_unix_ms, recorded_at_unix_ms, invalidated_at_unix_ms,
1858-
supersedes, superseded_by, created_at_unix_ms, last_accessed_unix_ms)
1859-
SELECT ?1, id, category, key, body_json, status, type, tags,
1860-
decay_score, retrieval_count, layer, topic_path, archived,
1861-
archive_reason, links, verified, source, always_on, certainty,
1862-
workspace_hash, agent_id, visibility, valid_from_unix_ms,
1863-
valid_to_unix_ms, recorded_at_unix_ms, ?2,
1864-
supersedes, ?3, created_at_unix_ms, last_accessed_unix_ms
1865-
FROM entities WHERE id = ?3",
1866-
params![history_id, now, id],
1867-
)?;
1851+
Self::snapshot_live_row_to_history(&tx, &history_id, now, &id)?;
18681852
}
18691853

18701854
tx.execute(
@@ -3614,10 +3598,43 @@ impl Database {
36143598
Ok(())
36153599
}
36163600

3601+
/// Snapshot the current live row of `id` into `entity_history`, retired at
3602+
/// transaction time `invalidated_at` and linked back to the live id via
3603+
/// `superseded_by`. All other columns (incl. the prior recorded_at) are
3604+
/// copied verbatim, so the version was live during
3605+
/// [recorded_at, invalidated_at). Shared by the remember supersession /
3606+
/// audited-re-assert path (#371) and the audited set_valid_to close (#373);
3607+
/// the caller owns the transaction and the follow-up stamp of the live row.
3608+
fn snapshot_live_row_to_history(
3609+
conn: &rusqlite::Connection,
3610+
history_id: &str,
3611+
invalidated_at: i64,
3612+
id: &str,
3613+
) -> Result<(), rusqlite::Error> {
3614+
conn.execute(
3615+
"INSERT INTO entity_history
3616+
(history_id, id, category, key, body_json, status, type, tags,
3617+
decay_score, retrieval_count, layer, topic_path, archived,
3618+
archive_reason, links, verified, source, always_on, certainty,
3619+
workspace_hash, agent_id, visibility, valid_from_unix_ms,
3620+
valid_to_unix_ms, recorded_at_unix_ms, invalidated_at_unix_ms,
3621+
supersedes, superseded_by, created_at_unix_ms, last_accessed_unix_ms)
3622+
SELECT ?1, id, category, key, body_json, status, type, tags,
3623+
decay_score, retrieval_count, layer, topic_path, archived,
3624+
archive_reason, links, verified, source, always_on, certainty,
3625+
workspace_hash, agent_id, visibility, valid_from_unix_ms,
3626+
valid_to_unix_ms, recorded_at_unix_ms, ?2,
3627+
supersedes, ?3, created_at_unix_ms, last_accessed_unix_ms
3628+
FROM entities WHERE id = ?3",
3629+
params![history_id, invalidated_at, id],
3630+
)?;
3631+
Ok(())
3632+
}
3633+
36173634
/// Close an entity's application-time period (#363): record when the fact
36183635
/// stopped being true in the world. Used by mimir_supersede — superseding
36193636
/// a fact ends its validity (at transaction time unless the caller says
3620-
/// when). Does not touch the transaction axis.
3637+
/// when).
36213638
///
36223639
/// Conservative by construction (#363 review):
36233640
/// * Refuses `valid_to <= valid_from` — an inverted period would shadow
@@ -3628,16 +3645,23 @@ impl Database {
36283645
/// revive it). Tightening (an earlier close) is allowed; when the
36293646
/// stored close is already at-or-before the requested one, it is kept.
36303647
///
3648+
/// AUDITED (#373, mirroring the #371/#372 audited re-assert): an effective
3649+
/// close/tighten snapshots the pre-change row to entity_history and
3650+
/// advances the live row's transaction time, so as_of/bitemporal_at at a
3651+
/// tx instant before the close still reconstruct the fact as open. A
3652+
/// no-op call (stored close kept) writes NO snapshot and touches nothing.
3653+
///
36313654
/// Returns the EFFECTIVE close instant (the requested one, or the earlier
36323655
/// stored close that was kept).
36333656
pub fn set_valid_to(&self, id: &str, valid_to: i64) -> Result<i64, Box<dyn std::error::Error>> {
36343657
let conn = self.conn()?;
3635-
let (eff_from, cur_to): (i64, Option<i64>) = conn.query_row(
3658+
let (eff_from, cur_to, old_rec): (i64, Option<i64>, i64) = conn.query_row(
36363659
"SELECT COALESCE(valid_from_unix_ms, recorded_at_unix_ms, created_at_unix_ms), \
3637-
valid_to_unix_ms \
3660+
valid_to_unix_ms, \
3661+
COALESCE(recorded_at_unix_ms, created_at_unix_ms) \
36383662
FROM entities WHERE id = ?1",
36393663
params![id],
3640-
|r| Ok((r.get(0)?, r.get(1)?)),
3664+
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
36413665
)?;
36423666
if valid_to <= eff_from {
36433667
return Err(format!(
@@ -3649,14 +3673,32 @@ impl Database {
36493673
if let Some(cur) = cur_to {
36503674
if cur <= valid_to {
36513675
// Already closed at or before the requested instant: keep the
3652-
// earlier close (never extend validity).
3676+
// earlier close (never extend validity). No-op — no snapshot.
36533677
return Ok(cur);
36543678
}
36553679
}
3656-
conn.execute(
3657-
"UPDATE entities SET valid_to_unix_ms = ?1 WHERE id = ?2",
3658-
params![valid_to, id],
3680+
// #373: audited close. Snapshot the pre-close version (invalidated at
3681+
// `now`, bumped strictly past the old recorded_at so the history
3682+
// window is never zero-width — same guarantee as remember's), advance
3683+
// the live row's recorded_at and link supersedes, and pin a
3684+
// still-NULL valid_from (pre-v9 rows a legacy binary may still write)
3685+
// to the OLD effective opening — readers derive effective valid_from
3686+
// via COALESCE(valid_from, recorded_at, …), so advancing recorded_at
3687+
// over a NULL valid_from would silently shift the opening to `now`.
3688+
let now = now_ms().max(old_rec + 1);
3689+
let history_id = format!(
3690+
"hist-{}",
3691+
uuid::Uuid::new_v4().to_string().replace('-', "")[..16].to_string()
3692+
);
3693+
let tx = conn.unchecked_transaction()?;
3694+
Self::snapshot_live_row_to_history(&tx, &history_id, now, id)?;
3695+
tx.execute(
3696+
"UPDATE entities SET valid_to_unix_ms = ?1, recorded_at_unix_ms = ?2,
3697+
supersedes = ?3, valid_from_unix_ms = COALESCE(valid_from_unix_ms, ?4)
3698+
WHERE id = ?5",
3699+
params![valid_to, now, history_id, eff_from, id],
36593700
)?;
3701+
tx.commit()?;
36603702
Ok(valid_to)
36613703
}
36623704

src/tools.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3228,6 +3228,147 @@ mod tests {
32283228
let _ = std::fs::remove_file(&path);
32293229
}
32303230

3231+
#[test]
3232+
fn set_valid_to_close_is_audited_and_noop_is_not() {
3233+
// #373: set_valid_to previously wrote no entity_history snapshot, so a
3234+
// close was invisible to transaction-time reconstruction — queries at
3235+
// a tx instant BEFORE the close reported the close anyway. An
3236+
// effective close must snapshot the pre-close (open) version; a no-op
3237+
// (stored close kept) must not.
3238+
use std::thread::sleep;
3239+
use std::time::Duration;
3240+
let (db, path) = temp_db();
3241+
3242+
handle_remember(
3243+
&db,
3244+
json!({"category": "facts", "key": "svt", "body_json": "{\"note\":\"open fact\"}"}),
3245+
)
3246+
.expect("open fact");
3247+
let ent = db.get_entity("facts", "svt").unwrap().unwrap();
3248+
assert!(db.history_versions("facts", "svt").unwrap().is_empty());
3249+
3250+
sleep(Duration::from_millis(5));
3251+
let tx_open = now_ms(); // while the fact was still believed open
3252+
sleep(Duration::from_millis(5));
3253+
3254+
let closed = db.set_valid_to(&ent.id, now_ms()).expect("close");
3255+
assert_eq!(
3256+
db.history_versions("facts", "svt").unwrap().len(),
3257+
1,
3258+
"an effective close must write exactly one snapshot"
3259+
);
3260+
3261+
// As of tx_open the fact reconstructs OPEN (the pre-close snapshot
3262+
// answers, valid_to unbounded)…
3263+
let r = handle_bitemporal(
3264+
&db,
3265+
json!({"category": "facts", "key": "svt",
3266+
"tx_at_unix_ms": tx_open, "valid_at_unix_ms": closed + 60_000}),
3267+
)
3268+
.expect("bitemporal pre-close knowledge");
3269+
let v: Value = serde_json::from_str(&r).unwrap();
3270+
assert_eq!(v["found"], json!(true), "pre-close knowledge must not show the close: {r}");
3271+
assert_eq!(v["valid_to_unix_ms"], Value::Null, "{r}");
3272+
assert_eq!(v["is_live_version"], json!(false), "{r}");
3273+
// …while current knowledge shows the close: same instant unanswerable,
3274+
// an in-period instant reports valid_to = closed.
3275+
let r = handle_bitemporal(
3276+
&db,
3277+
json!({"category": "facts", "key": "svt",
3278+
"tx_at_unix_ms": now_ms(), "valid_at_unix_ms": closed + 60_000}),
3279+
)
3280+
.expect("bitemporal post-close knowledge");
3281+
let v: Value = serde_json::from_str(&r).unwrap();
3282+
assert_eq!(v["found"], json!(false), "{r}");
3283+
let r = handle_bitemporal(
3284+
&db,
3285+
json!({"category": "facts", "key": "svt",
3286+
"tx_at_unix_ms": now_ms(), "valid_at_unix_ms": closed - 1}),
3287+
)
3288+
.expect("bitemporal in-period cell");
3289+
let v: Value = serde_json::from_str(&r).unwrap();
3290+
assert_eq!(v["found"], json!(true), "{r}");
3291+
assert_eq!(v["valid_to_unix_ms"], json!(closed), "{r}");
3292+
3293+
// No-op calls (same value, or a later one — the earlier close is
3294+
// kept) write NO snapshot.
3295+
assert_eq!(db.set_valid_to(&ent.id, closed).expect("same-value no-op"), closed);
3296+
assert_eq!(
3297+
db.set_valid_to(&ent.id, closed + 10_000).expect("would-extend no-op"),
3298+
closed
3299+
);
3300+
assert_eq!(
3301+
db.history_versions("facts", "svt").unwrap().len(),
3302+
1,
3303+
"no-op set_valid_to must not snapshot"
3304+
);
3305+
3306+
let _ = std::fs::remove_file(&path);
3307+
}
3308+
3309+
#[test]
3310+
fn supersede_close_inherits_the_audit_snapshot() {
3311+
// #373: mimir_supersede funnels through set_valid_to, so closing the
3312+
// old fact's period now snapshots it — the pre-supersede open version
3313+
// stays reconstructable at earlier transaction instants.
3314+
use std::thread::sleep;
3315+
use std::time::Duration;
3316+
let (db, path) = temp_db();
3317+
3318+
handle_remember(
3319+
&db,
3320+
json!({"category": "facts", "key": "sup-old", "body_json": "{\"note\":\"old\"}"}),
3321+
)
3322+
.expect("old");
3323+
handle_remember(
3324+
&db,
3325+
json!({"category": "facts", "key": "sup-new", "body_json": "{\"note\":\"new\"}"}),
3326+
)
3327+
.expect("new");
3328+
assert!(db.history_versions("facts", "sup-old").unwrap().is_empty());
3329+
3330+
sleep(Duration::from_millis(5));
3331+
let tx_open = now_ms();
3332+
sleep(Duration::from_millis(5));
3333+
3334+
let r = handle_supersede(
3335+
&db,
3336+
json!({"from_category": "facts", "from_key": "sup-old",
3337+
"to_category": "facts", "to_key": "sup-new"}),
3338+
)
3339+
.expect("supersede");
3340+
let v: Value = serde_json::from_str(&r).unwrap();
3341+
let closed_at = v["from_valid_to_unix_ms"].as_i64().expect("close instant");
3342+
3343+
assert_eq!(
3344+
db.history_versions("facts", "sup-old").unwrap().len(),
3345+
1,
3346+
"supersede's close must inherit the audit snapshot"
3347+
);
3348+
// Pre-supersede knowledge still believes the old fact open at an
3349+
// instant the close later excluded.
3350+
let r = handle_bitemporal(
3351+
&db,
3352+
json!({"category": "facts", "key": "sup-old",
3353+
"tx_at_unix_ms": tx_open, "valid_at_unix_ms": closed_at + 60_000}),
3354+
)
3355+
.expect("bitemporal pre-supersede knowledge");
3356+
let v: Value = serde_json::from_str(&r).unwrap();
3357+
assert_eq!(v["found"], json!(true), "{r}");
3358+
assert_eq!(v["valid_to_unix_ms"], Value::Null, "{r}");
3359+
// Current knowledge: closed.
3360+
let r = handle_bitemporal(
3361+
&db,
3362+
json!({"category": "facts", "key": "sup-old",
3363+
"tx_at_unix_ms": now_ms(), "valid_at_unix_ms": closed_at + 60_000}),
3364+
)
3365+
.expect("bitemporal post-supersede knowledge");
3366+
let v: Value = serde_json::from_str(&r).unwrap();
3367+
assert_eq!(v["found"], json!(false), "{r}");
3368+
3369+
let _ = std::fs::remove_file(&path);
3370+
}
3371+
32313372
#[test]
32323373
fn bitemporal_tool_reports_both_axes() {
32333374
use std::thread::sleep;

0 commit comments

Comments
 (0)