diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 6fa1562..784e09e 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -173,6 +173,95 @@ impl Registry { } } + /// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`, + /// the same "fixed-name sidecar, per-directory isolation" discipline (#191). + fn blueprint_names_path(&self) -> PathBuf { + self.path.with_file_name("blueprint_names.jsonl") + } + + /// Raw, ungated append: one label line, unconditionally, under the + /// registry's write mutex (#276). No shape or content-id check — + /// [`Registry::put_blueprint_label`] is the gated public entry; this + /// exists so tests can hand-append a stale line, mirroring + /// `append_identity_index`. + fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> { + let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let path = self.blueprint_names_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(&BlueprintNameLine { + name: name.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(()) + } + + /// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}` + /// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated + /// on a deterministic label shape (nonempty after trim, no whitespace, no + /// control characters — `RegistryError::BadLabel`, C29 discipline: shape + /// only, never content judgement) and on `content_id` resolving via + /// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint` + /// otherwise) — both checked BEFORE the write lock is taken. Re-labelling + /// an existing name appends a new line (a repoint); resolution is + /// latest-wins (see [`Registry::resolve_blueprint_label`]). + pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> { + gate_label_shape(name)?; + if self.get_blueprint(content_id)?.is_none() { + return Err(RegistryError::UnknownBlueprint(content_id.to_string())); + } + self.append_blueprint_label_line(name, content_id) + } + + /// Resolve a label to the content id of the LATEST line naming it, skipping + /// any line whose content id no longer resolves via `get_blueprint` + /// (self-repair spirit, #191) — so "latest" always means the latest VALID + /// entry, even when a later append points at a since-vanished id. Missing + /// sidecar file -> `None` (treat-as-empty, like `load_identity_index`). + pub fn resolve_blueprint_label(&self, name: &str) -> Option { + self.latest_blueprint_labels().remove(name) + } + + /// Every registered label, latest-wins-deduped and name-sorted — the + /// discovery surface's data source (`graph introspect --registered`). + /// Dangling entries (content id no longer resolves) are skipped. + pub fn blueprint_labels(&self) -> Vec<(String, String)> { + self.latest_blueprint_labels().into_iter().collect() + } + + /// Every content id currently stored in the blueprint store — the + /// candidate list a `use` ref's content-id-PREFIX resolution (#302 + /// semantics, #317) filters against, mirroring `list_process_ids`/ + /// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")` + /// name the same directory, so `list_doc_ids`'s filename-stem walk + /// applies unchanged. + pub fn list_blueprint_ids(&self) -> Result, RegistryError> { + self.list_doc_ids("blueprints") + } + + /// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl` + /// into a name -> content_id map (`BTreeMap` gives the name-sorted order + /// `blueprint_labels` promises for free). A garbage line (torn write, + /// manual edit) is skipped, like every other JSONL sidecar read in this + /// module. + fn latest_blueprint_labels(&self) -> std::collections::BTreeMap { + let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else { + return std::collections::BTreeMap::new(); + }; + let mut map = std::collections::BTreeMap::new(); + for raw in text.lines() { + let Ok(line) = serde_json::from_str::(raw) else { continue }; + if self.get_blueprint(&line.content_id).ok().flatten().is_some() { + map.insert(line.name, line.content_id); + } + } + map + } + /// The content-addressed document-store dir for one document kind — /// a sibling of the runs store, like `blueprints/`. fn doc_dir(&self, kind: &str) -> PathBuf { @@ -273,10 +362,42 @@ impl Registry { } } +/// One label->content mapping, one JSON object per line in +/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line +/// shape, mirrored for the label store. +#[derive(serde::Serialize, serde::Deserialize)] +struct BlueprintNameLine { + name: String, + content_id: String, +} + +/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty +/// after trim, no whitespace, no control characters. String shape only — +/// never content judgement (the C29 discipline `doc_gate` also follows). +fn gate_label_shape(name: &str) -> Result<(), RegistryError> { + if name.trim().is_empty() { + return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" }); + } + if name.chars().any(char::is_whitespace) { + return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" }); + } + if name.chars().any(|c| c.is_control()) { + return Err(RegistryError::BadLabel { + name: name.to_string(), + rule: "must not contain control characters", + }); + } + Ok(()) +} + /// C29 walk (#316): the root and every named nested composite must carry a /// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are -/// the Empty fault ("carries no doc"). -fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> { +/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction +/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite +/// before it enters a new composition (fail fast on a doc-less fetched +/// entry) — the one walk, two call sites (register's write-path gate, use's +/// read-path gate), never a second implementation. +pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> { let fault = match c.doc.as_deref() { None => Some(aura_core::DocGateFault::Empty), Some(d) => aura_core::doc_gate(&c.name, d).err(), @@ -938,6 +1059,14 @@ pub enum RegistryError { /// the store without a gate-passing doc. Registered artifacts are never /// retroactively invalidated — the gate guards the write path only. UndescribedComposite { name: String, fault: aura_core::DocGateFault }, + /// Label sidecar (#317): a `put_blueprint_label` name failing the + /// deterministic label-shape gate (nonempty after trim, no whitespace, no + /// control characters). By-identifier, naming the violated rule. + BadLabel { name: String, rule: &'static str }, + /// Label sidecar (#317): a `put_blueprint_label` content id that does not + /// resolve via [`Registry::get_blueprint`] — the label sidecar never + /// labels a blueprint the store does not have. + UnknownBlueprint(String), } impl fmt::Display for RegistryError { @@ -978,6 +1107,12 @@ impl fmt::Display for RegistryError { its name — a registered composite describes itself (C29)" ), }, + RegistryError::BadLabel { name, rule } => { + write!(f, "blueprint label {name:?}: {rule}") + } + RegistryError::UnknownBlueprint(id) => { + write!(f, "blueprint: no stored blueprint with content id \"{id}\"") + } } } } @@ -1172,6 +1307,134 @@ mod tests { ); } + /// A described fixture blueprint, ready for `put_blueprint`'s C29 gate — + /// `put_blueprint_label`'s content-id verification routes through + /// `get_blueprint`, so its target must first be a gate-passing entry. + fn described_fixture_blueprint(name: &str) -> String { + format!( + r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"# + ) + } + + /// Property (#317): a label round-trips to the blueprint it names, and + /// re-labelling the SAME name repoints it — resolution and the discovery + /// listing both answer with the latest target, never the first. + #[test] + fn blueprint_label_round_trips_latest_wins() { + let reg = Registry::open(temp_family_dir("label_round_trip")); + let a = described_fixture_blueprint("a"); + let b = described_fixture_blueprint("b"); + reg.put_blueprint("ha", &a).expect("put a"); + reg.put_blueprint("hb", &b).expect("put b"); + + reg.put_blueprint_label("x", "ha").expect("label x -> a"); + assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string())); + + reg.put_blueprint_label("x", "hb").expect("re-label x -> b"); + assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins"); + assert_eq!( + reg.blueprint_labels(), + vec![("x".to_string(), "hb".to_string())], + "the discovery listing dedups to the latest target", + ); + } + + /// Property (#317): the label-shape gate is deterministic string shape + /// only (nonempty after trim, no whitespace, no control characters) — + /// each bad shape refuses with `BadLabel` naming a (non-empty) rule. + #[test] + fn blueprint_label_refuses_bad_shapes() { + let reg = Registry::open(temp_family_dir("label_bad_shapes")); + let bp = described_fixture_blueprint("s"); + reg.put_blueprint("h1", &bp).expect("put"); + + for bad in ["", " ", "a b", "a\tb"] { + match reg.put_blueprint_label(bad, "h1") { + Err(RegistryError::BadLabel { name, rule }) => { + assert_eq!(name, bad); + assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}"); + } + other => panic!("expected BadLabel for {bad:?}, got {other:?}"), + } + } + } + + /// Property (#317): a label may only ever point at a blueprint the store + /// actually has — labelling an unregistered content id refuses + /// by-identifier, never silently writing a dangling line. + #[test] + fn blueprint_label_refuses_unknown_content_id() { + let reg = Registry::open(temp_family_dir("label_unknown_id")); + match reg.put_blueprint_label("x", "never-written") { + Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"), + other => panic!("expected UnknownBlueprint, got {other:?}"), + } + assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written"); + } + + /// Property (#317, #191 spirit): a dangling LATEST line (content id no + /// longer resolves) is skipped by resolution, which falls back to the + /// earlier still-valid line for the same name — latest-wins means + /// latest-VALID-wins. + #[test] + fn blueprint_label_resolution_skips_a_dangling_entry() { + let reg = Registry::open(temp_family_dir("label_dangling")); + let bp = described_fixture_blueprint("s"); + reg.put_blueprint("h1", &bp).expect("put"); + reg.put_blueprint_label("x", "h1").expect("label x -> h1"); + + // hand-append a dangling line after the valid one, mirroring + // `append_identity_index`'s stale-line test shape: the content id it + // names was never registered. + reg.append_blueprint_label_line("x", "never-written").expect("append stale line"); + + assert_eq!( + reg.resolve_blueprint_label("x"), + Some("h1".to_string()), + "the dangling latest line is skipped; the earlier valid line for \"x\" wins", + ); + assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]); + } + + /// Property (#317, #276 discipline): N threads appending labels through + /// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed — + /// every successful append surviving as its own intact, resolvable line, + /// none torn or merged (mirrors `concurrent_appends_keep_the_family_ + /// store_well_formed`). + #[test] + fn concurrent_label_appends_keep_the_sidecar_well_formed() { + let reg = Registry::open(temp_family_dir("concurrent_label")); + let bp = described_fixture_blueprint("s"); + reg.put_blueprint("h1", &bp).expect("put"); + let n_threads = 8usize; + let iters = 50usize; + + let total_ok: usize = std::thread::scope(|scope| { + let handles: Vec<_> = (0..n_threads) + .map(|tid| { + let reg = ® + scope.spawn(move || { + let mut ok = 0usize; + for iter in 0..iters { + let name = format!("t{tid}-l{iter}"); + if reg.put_blueprint_label(&name, "h1").is_ok() { + ok += 1; + } + } + ok + }) + }) + .collect(); + handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum() + }); + + assert_eq!( + reg.blueprint_labels().len(), + total_ok, + "every successful label append must survive as its own intact, resolvable entry", + ); + } + #[test] fn document_stores_round_trip_by_content_id() { let reg = Registry::open(temp_family_dir("document_store_round_trip"));