test(registry): RED pin — twin-store identity index must converge, not grow

Executable spec for the audit follow-up on the identity-index cycle:
once one full repair walk has run over a same-identity-twin store (two
blueprints sharing one identity id, distinct content ids), a further
scan-triggering lookup must leave blueprint_identity_index.jsonl's line
count unchanged while the twin identity still resolves. RED against the
current per-entry snapshot comparison (appends one alternating line per
scan-triggering lookup while twins coexist).

refs #191
This commit is contained in:
2026-07-17 14:24:42 +02:00
parent 88a1c28954
commit 35865bb934
+53
View File
@@ -2071,6 +2071,59 @@ mod tests {
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
}
/// Property (#191 audit): the identity-index sidecar CONVERGES for a
/// same-identity-twin store — two blueprints sharing one identity id but
/// with distinct content ids (they differ only in identity-blind debug
/// symbols). Once one full repair walk has run, a further scan-triggering
/// lookup appends nothing (the file does not grow) while resolution
/// answers stay unchanged. The pre-fix repair pass compares each walked
/// entry against a mid-walk-stale snapshot, so with twins the snapshot can
/// never equal both entries and every full-scan lookup appends one more
/// (alternating) line — unbounded growth.
#[test]
fn identity_index_twin_store_converges_and_stops_growing() {
let reg = Registry::open(temp_family_dir("identity_twin_converges"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
// Two same-identity twins: the one-node Bias composite, built with
// different debug names. Debug symbols are identity-blind, so the two
// share one identity id but hash to distinct content ids.
let (_json_a, content_a, identity) = seed_blueprint(&reg, &bias_fixture());
let twin = aura_engine::Composite::new(
"fixture_twin",
vec![aura_std::Bias::builder().named("b_twin").into()],
vec![],
vec![],
vec![],
);
let (_json_b, content_b, identity_twin) = seed_blueprint(&reg, &twin);
assert_eq!(identity, identity_twin, "twins must share one identity id");
assert_ne!(content_a, content_b, "twins must have distinct content ids");
// One converging walk: an absent identity always scans the whole
// store (only a scan proves absence), repairing the index.
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
let converged_lines = fs::read_to_string(reg.identity_index_path())
.map(|t| t.lines().count())
.unwrap_or(0);
// A further scan-triggering lookup over the CONVERGED index must not
// append another line (pre-fix: it appends one alternating twin line).
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
let after_lines = fs::read_to_string(reg.identity_index_path())
.map(|t| t.lines().count())
.unwrap_or(0);
assert_eq!(
after_lines, converged_lines,
"a converged twin index must not grow on a further scan-triggering lookup",
);
// Resolution answers stay unchanged: the twin identity still resolves
// to a valid stored blueprint for that identity (preservation pin).
let found = reg.find_blueprint_by_identity(&identity, &resolve).expect("io ok");
assert!(found.is_some(), "the twin identity still resolves to a stored blueprint");
}
/// Property (#246): a campaign axis naming a BOUND param (not an open
/// one) is checked exactly like an open-param axis — a kind-correct axis
/// over the bound path is fault-free, and a kind-mismatched one over that