feat(aura-registry): blueprint name labels — append-only sidecar, latest-wins
A registered blueprint gains a legible, re-pointable handle: `put_blueprint_label`
appends {"name","content_id"} lines to blueprint_names.jsonl (behind the one
registry write mutex, #276), `resolve_blueprint_label` reads latest-wins and
skips dangling entries (self-repair posture, #191), `blueprint_labels` returns
the deduped, name-sorted discovery set. Label shape is gated deterministically
(BadLabel: nonempty after trim, no whitespace/control chars — C29-style shape
gate, never content judgement). Re-labelling repoints; old targets stay
reachable by content id (append-only, nothing invalidated).
Also: gate_composite_docs goes pub and list_blueprint_ids lands — the CLI's
use-seam C29 walk and #302-style prefix resolution reuse the existing
mechanisms instead of duplicating them (review-sanctioned deviation from the
plan's file list).
Design: #317 (fork decisions minuted as issue comments 4621/4627 — the
name channel is the store/CLI layer C24 §"Enforcement shift" explicitly
permits; the engine never resolves labels).
refs #317
This commit is contained in:
@@ -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<String> {
|
||||||
|
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<Vec<String>, 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<String, String> {
|
||||||
|
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::<BlueprintNameLine>(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 —
|
/// The content-addressed document-store dir for one document kind —
|
||||||
/// a sibling of the runs store, like `blueprints/`.
|
/// a sibling of the runs store, like `blueprints/`.
|
||||||
fn doc_dir(&self, kind: &str) -> PathBuf {
|
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
|
/// C29 walk (#316): the root and every named nested composite must carry a
|
||||||
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
||||||
/// the Empty fault ("carries no doc").
|
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
|
||||||
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
|
/// 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() {
|
let fault = match c.doc.as_deref() {
|
||||||
None => Some(aura_core::DocGateFault::Empty),
|
None => Some(aura_core::DocGateFault::Empty),
|
||||||
Some(d) => aura_core::doc_gate(&c.name, d).err(),
|
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
|
/// the store without a gate-passing doc. Registered artifacts are never
|
||||||
/// retroactively invalidated — the gate guards the write path only.
|
/// retroactively invalidated — the gate guards the write path only.
|
||||||
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
|
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 {
|
impl fmt::Display for RegistryError {
|
||||||
@@ -978,6 +1107,12 @@ impl fmt::Display for RegistryError {
|
|||||||
its name — a registered composite describes itself (C29)"
|
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]
|
#[test]
|
||||||
fn document_stores_round_trip_by_content_id() {
|
fn document_stores_round_trip_by_content_id() {
|
||||||
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
||||||
|
|||||||
Reference in New Issue
Block a user