feat(registry): index-first identity resolution with self-repairing sidecar

Replace the per-call O(store) scan in find_blueprint_by_identity with a
persistent identity-id -> content-id sidecar index
(blueprint_identity_index.jsonl, sibling of runs.jsonl). Lookups consult
the index first and VERIFY every hit by loading that one blueprint under
the current roster and recomputing its identity id -- the index is a
cache, never an oracle, so results stay scan-identical under roster
drift, store surgery, or index corruption (up to the same-identity-twin
choice, unspecified in both paths and documented on the fn). Any miss or
failed verification runs the old scan as a full-store repair pass: no
early return, best-effort append of every absent-or-different mapping,
last line wins so later repairs heal stale lines. No write-path, engine,
or caller changes; a pre-index store backfills itself on the first miss;
a read-only store keeps scanning as before.

Decided against put-time index maintenance: it would need a roster-free
doc-level identity function whose equivalence to the loaded-composite
path no green test ratifies (the composite path canonicalizes
structurally on load); the repair path is the load-bearing mechanism
either way. Decision log: the issue's comments.

Verification: 7 new aura-registry tests (backfill, verified-hit-no-walk
via a poison-dir probe, one-pass legacy backfill, stale-line healing,
unloadable-under-roster preservation, garbage tolerance) plus a CLI e2e
(campaign validate resolving an identity ref cold then warm); workspace
suite 1346 passed / 0 failed; clippy -D warnings clean; doc build clean.

closes #191
This commit is contained in:
2026-07-17 14:13:48 +02:00
parent bfb8648925
commit 88a1c28954
2 changed files with 454 additions and 6 deletions
+296 -6
View File
@@ -49,8 +49,10 @@ pub struct Registry {
/// read-the-counter-then-write critical section (`append_family`,
/// `append_campaign_run` in `lineage.rs`), so concurrent writers through
/// one handle also see a consistent per-name run counter. Store-agnostic:
/// it guards all three JSONL append paths — the flat runs store
/// ([`Registry::append`]), `families.jsonl`, and `campaign_runs.jsonl`
/// it guards all four JSONL append paths — the flat runs store
/// ([`Registry::append`]), `families.jsonl`, `campaign_runs.jsonl`, and the
/// blueprint identity index (`blueprint_identity_index.jsonl`,
/// `append_identity_index`)
/// (they never contend on the same file, but share this one lock).
/// In-process only; cross-process locking stays out of contract.
append_write_lock: std::sync::Mutex<()>,
@@ -240,6 +242,17 @@ pub enum RefFault {
BindingRoleUnknown { role: String, roles: Vec<String> },
}
/// One identity-index mapping: the identity id (debug-symbol-blind hash) of
/// a stored blueprint → the content id (byte-exact hash) it lives at. One
/// JSON line per mapping in the `blueprint_identity_index.jsonl` sidecar
/// (#191); duplicate identity ids resolve last-line-wins at read time, so a
/// later repair append heals an earlier stale line.
#[derive(serde::Serialize, serde::Deserialize)]
struct IdentityIndexLine {
identity_id: String,
content_id: String,
}
impl Registry {
/// Referential tier: resolve the campaign's references against the
/// project's stores and check each axis (name AND declared kind) against
@@ -350,28 +363,128 @@ impl Registry {
Ok(faults)
}
/// Scan the blueprint store for a blueprint whose identity id matches —
/// The identity-index sidecar path — the `families.jsonl` discipline: a
/// fixed-name sibling of the runs store, so isolation stays
/// per-directory (#191).
fn identity_index_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_identity_index.jsonl")
}
/// Append one identity→content mapping as a single JSON line, creating
/// the file (and its parent directory) if absent. Serialized by the
/// internal `append_write_lock` (#276) like every other JSONL append
/// path. Callers treat the result as best-effort: the index is a cache,
/// and a cache write must never degrade a correct read.
fn append_identity_index(&self, identity_id: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.identity_index_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&IdentityIndexLine {
identity_id: identity_id.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Read the index into a LAST-line-wins identity→content map. The index
/// is a pure cache with the scan as its truth path, so reads never fail
/// the lookup: a missing or unreadable file is an empty map, and a line
/// that does not parse (torn write, manual edit) is skipped.
fn load_identity_index(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.identity_index_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
if let Ok(line) = serde_json::from_str::<IdentityIndexLine>(raw) {
map.insert(line.identity_id, line.content_id);
}
}
map
}
/// Resolve an identity id to a stored blueprint's canonical JSON —
/// public so a campaign run resolves the same identity refs the
/// referential tier validates.
///
/// Index-first (#191): the persistent `blueprint_identity_index.jsonl`
/// sidecar is consulted and every hit is VERIFIED by loading that one
/// blueprint under the current `resolve` roster and recomputing its
/// identity id — the index is a cache, never an oracle, so a verified hit
/// is always a *valid* match (same identity id) under roster drift, store
/// surgery, or index corruption. Any miss or failed verification falls
/// back to the scan, which doubles as a full-store repair pass: it walks
/// every entry (no early return), recomputes each loadable entry's
/// identity id exactly as before, and best-effort appends every mapping
/// the index lacks or has wrong. A pre-index store therefore backfills
/// itself on the first miss; a read-only store simply keeps scanning as
/// it always has.
///
/// One corner is *not* byte-identical to the scan: two stored blueprints
/// that differ only in fields excluded from the identity hash (e.g. debug
/// names) share one `identity_id` but have distinct `content_id`s. The
/// scan always answers with the first `read_dir` match; a verified index
/// hit answers with whichever of those content ids the index currently
/// maps the identity to, which need not be the first match. Both are
/// valid blueprints for that identity, so the answer is correct either
/// way, just not guaranteed to pick the same one as an uncached scan.
pub fn find_blueprint_by_identity(
&self,
identity_id: &str,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Option<String>, RegistryError> {
let index = self.load_identity_index();
if let Some(content_id) = index.get(identity_id)
&& let Some(json) = self.get_blueprint(content_id)?
&& let Ok(composite) = blueprint_from_json(&json, resolve)
&& let Ok(identity_json) = blueprint_identity_json(&composite)
&& aura_research::content_id_of(&identity_json) == identity_id
{
return Ok(Some(json));
}
// Miss or failed verification: today's scan, as a repair pass. The
// answer comes from the walk (first `read_dir` match, as before),
// never from the repaired index.
let entries = match fs::read_dir(self.blueprints_dir()) {
Ok(e) => e,
Err(_) => return Ok(None), // no store yet -> nothing matches
};
let mut found = None;
for entry in entries {
let entry = entry?;
let json = fs::read_to_string(entry.path())?;
let Ok(composite) = blueprint_from_json(&json, resolve) else { continue };
let Ok(identity_json) = blueprint_identity_json(&composite) else { continue };
if aura_research::content_id_of(&identity_json) == identity_id {
return Ok(Some(json));
let entry_identity = aura_research::content_id_of(&identity_json);
// The filename stem is the content id — `blueprint_path`'s
// write-one/read-one mapping, read back.
let Some(content_id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from))
else {
continue;
};
if index.get(&entry_identity) != Some(&content_id) {
// absent-or-different only, so a walk over an already-correct
// index appends nothing — *for the common one-content-per-
// identity case*. In the duplicate-identity/distinct-content
// corner (two stored blueprints sharing an identity id, e.g.
// differing only in debug names), `index` is this call's
// snapshot and is never updated mid-walk, so every entry
// whose content id differs from that stale snapshot appends
// a line, one per full-scan lookup, even though each write is
// individually a valid mapping. Best-effort regardless — a
// cache write must never degrade a correct read.
let _ = self.append_identity_index(&entry_identity, &content_id);
}
if found.is_none() && entry_identity == identity_id {
found = Some(json);
}
}
Ok(None)
Ok(found)
}
}
@@ -1781,6 +1894,183 @@ mod tests {
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
}
/// Property (#191): the identity-index sidecar is a last-line-wins cache
/// over identity id → content id — a missing file reads as empty (no
/// error, since the scan is the truth path), a later append for the same
/// identity id heals an earlier stale line, and a garbage line (torn
/// concurrent write, manual edit) is skipped rather than failing the read.
#[test]
fn identity_index_reads_last_line_wins_and_skips_garbage() {
let reg = Registry::open(temp_family_dir("identity_index_read"));
// missing file: empty map, no error — the index is a cache and the
// scan is the truth path.
assert!(reg.load_identity_index().is_empty());
reg.append_identity_index("id-a", "content-1").expect("append");
reg.append_identity_index("id-b", "content-2").expect("append");
// a stale line for id-a is healed by a later append: last line wins.
reg.append_identity_index("id-a", "content-3").expect("append");
// garbage lines (torn concurrent write, manual edit) are skipped.
let text = fs::read_to_string(reg.identity_index_path()).expect("index exists");
fs::write(reg.identity_index_path(), format!("{text}not json\n")).expect("garbage line");
let map = reg.load_identity_index();
assert_eq!(map.get("id-a").map(String::as_str), Some("content-3"));
assert_eq!(map.get("id-b").map(String::as_str), Some("content-2"));
assert_eq!(map.len(), 2);
}
/// Store a composite as a canonical blueprint and return its
/// (blueprint_json, content_id, identity_id) — the seeding pattern of
/// `referential_tier_resolves_refs_and_checks_axes`, factored for the
/// identity-index tests.
fn seed_blueprint(reg: &Registry, composite: &aura_engine::Composite) -> (String, String, String) {
let blueprint_json = aura_engine::blueprint_to_json(composite).expect("serializes");
let content_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&content_id, &blueprint_json).expect("seed blueprint");
let identity_json = blueprint_identity_json(composite).expect("identity form");
let identity_id = aura_research::content_id_of(&identity_json);
(blueprint_json, content_id, identity_id)
}
/// The referential-tier fixture: a one-node composite over the zero-arg
/// vocabulary node `Bias`, loadable via `std_vocabulary`.
fn bias_fixture() -> aura_engine::Composite {
aura_engine::Composite::new(
"fixture",
vec![aura_std::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
)
}
/// A second, identity-distinct fixture: same shape over the zero-arg
/// vocabulary node `Delay`. Distinct node types are identity-bearing
/// (only debug names are identity-blind), so the two fixtures give the
/// store two entries with different identity ids.
fn delay_fixture() -> aura_engine::Composite {
aura_engine::Composite::new(
"fixture2",
vec![aura_std::Delay::builder().named("d").into()],
vec![],
vec![],
vec![],
)
}
#[test]
fn identity_index_lookup_backfills_and_resolves() {
let reg = Registry::open(temp_family_dir("identity_backfill"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (blueprint_json, content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// no index yet: the lookup answers via the scan fallback...
assert!(!reg.identity_index_path().exists());
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
// ...and repairs the index in the same pass.
assert_eq!(reg.load_identity_index().get(&identity_id), Some(&content_id));
// the second lookup resolves identically.
let again = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(again.as_deref(), Some(blueprint_json.as_str()));
}
#[test]
fn identity_index_verified_hit_answers_without_a_store_walk() {
let reg = Registry::open(temp_family_dir("identity_hit_no_walk"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (blueprint_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// backfill the index, then poison the store: a DIRECTORY named like
// a blueprint makes any scan's read_to_string error, so only a
// walk-free hit can succeed past it.
reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill");
fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir");
// verified hit: no walk, the poison is never touched.
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("hit path");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
// an unknown identity must scan — and the poison proves the scan
// would have bitten: the lookup errors instead of answering.
assert!(reg.find_blueprint_by_identity("0000", &resolve).is_err());
}
#[test]
fn identity_index_legacy_store_backfills_in_one_pass() {
let reg = Registry::open(temp_family_dir("identity_legacy_backfill"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (_json_a, content_a, identity_a) = seed_blueprint(&reg, &bias_fixture());
let (_json_b, content_b, identity_b) = seed_blueprint(&reg, &delay_fixture());
assert_ne!(identity_a, identity_b, "fixtures must be identity-distinct");
assert!(!reg.identity_index_path().exists());
// one miss-lookup (an absent identity) walks the store once and
// returns None — only the scan can prove absence...
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
// ...and that single walk backfills a mapping for EVERY loadable
// entry, not just the queried one.
let map = reg.load_identity_index();
assert_eq!(map.get(&identity_a), Some(&content_a));
assert_eq!(map.get(&identity_b), Some(&content_b));
assert_eq!(map.len(), 2);
}
#[test]
fn identity_index_stale_lines_degrade_to_scan_and_heal() {
let reg = Registry::open(temp_family_dir("identity_stale_heals"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (json_a, content_a, identity_a) = seed_blueprint(&reg, &bias_fixture());
let (_json_b, content_b, _identity_b) = seed_blueprint(&reg, &delay_fixture());
// stale shape 1: identity → an absent content id. Verify-on-hit
// cannot load it, the scan answers, the repair appends the correct
// pair behind the stale line — last line wins.
reg.append_identity_index(&identity_a, "feedface").expect("stale line");
let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(json_a.as_str()));
assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a));
// stale shape 2: identity → an EXISTING content id whose recomputed
// identity differs. Verify-on-hit refuses, the scan answers, the
// repair heals again.
reg.append_identity_index(&identity_a, &content_b).expect("stale line");
let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(json_a.as_str()));
assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a));
// healed for good: the hit path now clears a scan-poisoned store.
fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir");
let again = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("hit path");
assert_eq!(again.as_deref(), Some(json_a.as_str()));
}
#[test]
fn identity_index_unloadable_under_roster_stays_unmatched() {
let reg = Registry::open(temp_family_dir("identity_unloadable"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill");
// under a roster that cannot load the blueprint the verified hit
// fails and the scan skips the entry — `IdentityUnmatched` semantics
// preserved exactly (a preservation pin: green before AND after the
// lookup rework).
let empty = |_t: &str| None::<PrimitiveBuilder>;
assert_eq!(reg.find_blueprint_by_identity(&identity_id, &empty).expect("io ok"), None);
}
#[test]
fn identity_index_garbage_line_does_not_break_resolution() {
let reg = Registry::open(temp_family_dir("identity_garbage_line"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
let (blueprint_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// a wholly-garbage index file must not break resolution (a
// preservation pin — the scan is the truth path).
fs::write(reg.identity_index_path(), "not json at all\n").expect("garbage index");
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
}
/// 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