diff --git a/crates/eureka-pipeline/README.md b/crates/eureka-pipeline/README.md index 5d9c633..25fa726 100644 --- a/crates/eureka-pipeline/README.md +++ b/crates/eureka-pipeline/README.md @@ -18,13 +18,6 @@ and every encoding-relevant variant against `golden/envelopes.txt`. A diff there is a wire change; do not regenerate it to make the test pass. An additive field adds golden lines and never changes an existing one. -Opt-in, against a real store: `stored_documents_read_back` decodes, validates -and keys every stored pipeline document and asserts that it re-encodes to the -stored key, type and payload bytes. Take a copy of the store (the test copies -the file again before opening it, so the copy may be read-only), never open the -live one: - -```sh -sudo cp /var/lib/gamecult/huginn/minds/eureka/mind.redb "$SNAP/minds/eureka/mind.redb" -HUGINN_MIND_SNAPSHOT="$SNAP" cargo test -p eureka-pipeline -- --ignored --exact tests::stored_documents_read_back --nocapture -``` +The real-store gate is huginn-mind's `#[ignore]` `query::tests::stored_documents_read_back`, +which checks these stored bytes and the read path together (the leaf cannot see the read +path); the Huginn Upgrade runbook in gamecult-ops runs it. diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index d19d192..9288d4c 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -2747,79 +2747,6 @@ mod tests { assert!(!Sha("5f98228a".into()).names_same_commit(&Sha("5f98228b".into())), "equal length, differing"); } - /// stored-documents-valid and stored-bytes-reproduce, on the real mind. - /// `HUGINN_MIND_SNAPSHOT` is a copy of a state root holding - /// `minds/eureka/mind.redb`; the test copies the store file into a scratch - /// directory first (redb opens read-write), so the snapshot may be - /// read-only. Read through cultcache-rs's own store. Each pipeline - /// document must re-prepare to its stored envelope: key, type and payload - /// bytes. - #[test] - #[ignore = "needs HUGINN_MIND_SNAPSHOT, a copy of a Huginn state root; see the crate README"] - fn stored_documents_read_back() { - use cultcache_rs::{CacheBackingStore, OwnedRedbMessagePackBackingStore}; - let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names the snapshot state root"); - let scratch = std::env::temp_dir().join(format!("eureka-pipeline-readback-{}", std::process::id())); - std::fs::create_dir_all(&scratch).expect("scratch directory"); - let working = scratch.join("mind.redb"); - std::fs::copy(Path::new(&root).join("minds").join("eureka").join("mind.redb"), &working).expect("the snapshot copies"); - let store = OwnedRedbMessagePackBackingStore::new(&working).expect("the snapshot opens"); - let cache = schema_cache().expect("the registrar registers"); - let envelopes = store.pull_all().expect("the snapshot reads"); - let mut stored = Vec::new(); - for envelope in &envelopes { - match PipelineDocument::decode(envelope) { - Ok(document) => { - assert_eq!(document.validate(), Ok(()), "{}: valid", envelope.key); - assert_eq!(pipeline_key(&document).as_deref(), Ok(envelope.key.as_str()), "{}: key derives byte for byte", envelope.key); - let again = document.prepare(&cache).unwrap_or_else(|error| panic!("{}: does not re-prepare: {error}", envelope.key)); - assert_eq!( - (&again.key, &again.r#type, &again.payload), - (&envelope.key, &envelope.r#type, &envelope.payload), - "{}: re-encodes to the stored bytes", - envelope.key - ); - stored.push((document.kind(), envelope.key.clone())); - } - Err(PipelineRefusal::ForeignStore { .. }) => {} - Err(error) => panic!("{}: does not decode: {error}", envelope.key), - } - } - assert!(stored.len() > 200, "{} pipeline documents read", stored.len()); - let mut subjects = 0; - let resolvable = |kind: &PipelineKind| !matches!(kind, PipelineKind::Resolution | PipelineKind::Campaign | PipelineKind::Instance); - for (kind, key) in stored.iter().filter(|(kind, _)| resolvable(kind)) { - let one = resolved_at(*kind, key, 1).unwrap_or_else(|error| panic!("{key}: no resolution keys: {error}")); - let two = resolved_at(PipelineKind::Resolution, &one, 1).unwrap_or_else(|error| panic!("{key}: no withdrawal keys: {error}")); - for derived in [&one, &two] { - pipeline_id("read_back", derived, PipelineKind::Resolution).unwrap_or_else(|error| panic!("{derived}: does not parse: {error}")); - } - subjects += 1; - } - for (_, key) in stored.iter().filter(|(kind, _)| *kind == PipelineKind::Resolution) { - resolved_at(PipelineKind::Resolution, key, 1).unwrap_or_else(|error| panic!("{key}: cannot be withdrawn: {error}")); - } - // The six findings that could not be resolved before this cut. - let stuck = [ - "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", - "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", - "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", - "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", - "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", - "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", - ]; - for label in stuck { - let found = stored - .iter() - .find(|(kind, key)| *kind == PipelineKind::Finding && key.ends_with(label)) - .unwrap_or_else(|| panic!("{label}: not in the snapshot")); - let one = resolved_at(PipelineKind::Finding, &found.1, 1).unwrap_or_else(|error| panic!("{label}: still stuck: {error}")); - assert!(one.split(':').nth(2).is_some_and(|local| local.len() > SUBJECT_LOCAL_MAX), "{label}: the old bound refused it"); - } - eprintln!("read back {} pipeline documents, {subjects} subjects", stored.len()); - let _ = std::fs::remove_dir_all(&scratch); - } - /// The total bound, in the one place it lives: parts that are each a legal /// label compose a local wider than 64 bytes and are refused as a whole. #[test] diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index c33ee6a..5ed5049 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -362,7 +362,6 @@ fn snapshot(cache: &CultCache) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::admission::PipelineAdmissionOutcome; use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; use crate::store::test_stores::MemoryStore; use cultcache_rs::CacheBackingStore; @@ -786,94 +785,4 @@ mod tests { })) ); } - - /// stored-documents-valid and every-subject-resolvable, against the real - /// mind: every stored document views back unchanged; the six findings the - /// 64-byte bound left unresolvable take a resolution; and a stored - /// resolution whose withdrawal key is over 64 bytes is withdrawn. Run it - /// on a scratch copy of the state root only: it writes. - #[test] - #[ignore = "reads and writes the scratch copy of a live state root named by HUGINN_MIND_SNAPSHOT"] - fn snapshot_admits_and_reads_back() { - use crate::fixtures::{admit, r, resolution, withdrawn}; - use eureka_pipeline::{PipelineRef, ResolutionOutcome, Short, pipeline_key}; - - let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root"); - let mut mind = Mind::open(Path::new(&root), &slug("eureka")).unwrap(); - - // The census is taken apart from the loop that checks each document. - let pipeline_envelopes = mind - .envelopes() - .iter() - .filter(|envelope| PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type)) - .count(); - assert!(pipeline_envelopes > 0, "the snapshot held no pipeline document"); - - let mut viewed = 0; - let mut resolutions = Vec::new(); - for envelope in mind.envelopes() { - let Some(kind) = PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == envelope.r#type) else { continue }; - let stored = PipelineDocument::decode(envelope).unwrap(); - stored.validate().unwrap_or_else(|refusal| panic!("{} no longer validates: {refusal:?}", envelope.key)); - let rekeyed = pipeline_key(&stored).unwrap_or_else(|refusal| panic!("{} no longer keys: {refusal:?}", envelope.key)); - assert_eq!(rekeyed, envelope.key, "stored key differs from the key the leaf computes"); - let view = mind - .view(&PipelineRef { kind, id: Short(envelope.key.clone()) }) - .unwrap() - .unwrap_or_else(|| panic!("{} does not view", envelope.key)); - assert_eq!(view.document, stored, "{}", envelope.key); - if let PipelineDocument::Resolution(held) = &stored - && !matches!(held.outcome, eureka_pipeline::ResolutionOutcome::Withdrawn { .. }) - && !envelope.key.contains(":resolution:resolution.") - { - resolutions.push(envelope.key.clone()); - } - viewed += 1; - } - assert_eq!(viewed, pipeline_envelopes, "every pipeline envelope validates, rekeys and views back"); - println!("validated, rekeyed and viewed {viewed} stored documents unchanged"); - - let stuck = [ - "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", - "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", - "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", - "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", - "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", - "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", - ]; - let mut resolved = 0; - for local in stuck { - let subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}")); - let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]); - match outcome { - PipelineAdmissionOutcome::Committed { writes, .. } => { - assert_eq!(writes.len(), 1, "{local}"); - resolved += 1; - } - other => panic!("stuck finding {local} was not resolved: {other:?}"), - } - } - - assert_eq!(resolved, stuck.len(), "every stuck finding resolves"); - - let mut candidates = Vec::new(); - for key in resolutions { - let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn()); - let withdrawal_key = pipeline_key(&withdrawal).unwrap_or_else(|refusal| panic!("withdrawal of {key} does not key: {refusal:?}")); - if withdrawal_key.rsplit(':').next().unwrap().len() > 64 { - candidates.push((key, withdrawal)); - } - } - assert!(!candidates.is_empty(), "no stored resolution had a withdrawal key over 64 bytes"); - let wanted = candidates.len(); - let mut withdrew = 0; - for (key, withdrawal) in candidates { - match admit(&mut mind, vec![withdrawal]) { - PipelineAdmissionOutcome::Committed { .. } => withdrew += 1, - other => panic!("withdrawal of {key} was not committed: {other:?}"), - } - } - assert_eq!(withdrew, wanted, "every candidate withdraws"); - println!("withdrew {withdrew} resolutions whose withdrawal keys exceed 64 bytes"); - } } diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 1f1945b..54a028e 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -28,13 +28,13 @@ use cultnet_rs::{ use eureka_pipeline::{ ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, - Short, Slug, Title, + Short, Slug, Title, pipeline_key, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::docs::{CitationRole, Docs, Held, citations}; -use crate::mind::Mind; +use crate::docs::{CitationRole, Docs, Held, citations, kind_of_type}; +use crate::mind::{Mind, schema_cache}; use crate::receipt::{self, PipelineProvenance}; use crate::refusal::MindRefusal; use crate::rows::{SelectionRow, Vocabulary, refuse_values}; @@ -394,6 +394,12 @@ impl Reader { self.docs.image.iter().find(|held| held.kind == kind && held.key == key) } + /// The lookup `Mind::view` answers through, so a caller holding one `Reader` + /// views many documents from one snapshot. + fn view(&self, id: &PipelineRef) -> Result, MindRefusal> { + self.held(id.kind, &id.id.0).map(|held| self.view_of(held)).transpose() + } + fn view_of(&self, held: &Held) -> Result { let admission = self.facts.of(held)?; let status = match self.docs.closing_resolution(held.kind, &held.key) { @@ -455,8 +461,57 @@ impl Mind { /// vocabulary for one rule. pub fn view(&self, id: &PipelineRef) -> Result, MindRefusal> { id.validate_ref()?; + Reader::at(self, receipt::head(self)?)?.view(id) + } + + /// The upgrade gate (ruling readback-gate-and-materialized-image): every + /// stored pipeline document decodes, validates, rekeys, re-prepares to its + /// stored key, type and payload bytes, and views back unchanged through the + /// read path `view` uses; every pipeline write a receipt names is stored, + /// and every stored document has its receipt. Returns how many documents + /// it read back; an empty store is a fault. One `Reader` serves every view, + /// because `Reader::at` is the expensive build. Admits nothing. A fault + /// names the document's key and what failed, never its content. + pub fn read_back(&self) -> Result { + self.read_back_through(Reader::view) + } + + /// The gate's one body; `lookup` is the read path under test, which tests + /// replace with a lying one to prove the view comparison refuses. + fn read_back_through( + &self, + lookup: impl Fn(&Reader, &PipelineRef) -> Result, MindRefusal>, + ) -> Result { + let cache = schema_cache()?; let reader = Reader::at(self, receipt::head(self)?)?; - reader.held(id.kind, &id.id.0).map(|held| reader.view_of(held)).transpose() + let mut count = 0; + for envelope in self.envelopes() { + let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; + let key = &envelope.key; + let stored = PipelineDocument::decode(envelope).map_err(|_| integrity(&envelope.r#type, key, "does not decode"))?; + stored.validate().map_err(|_| integrity(&envelope.r#type, key, "no longer validates"))?; + if pipeline_key(&stored).map_err(|_| integrity(&envelope.r#type, key, "no longer keys"))? != *key { + return Err(integrity(&envelope.r#type, key, "is stored under a key the leaf no longer computes")); + } + let again = stored.prepare(&cache).map_err(|_| integrity(&envelope.r#type, key, "does not re-prepare"))?; + if (&again.key, &again.r#type, &again.payload) != (key, &envelope.r#type, &envelope.payload) { + return Err(integrity(&envelope.r#type, key, "does not re-encode to its stored bytes")); + } + let viewed = lookup(&reader, &PipelineRef { kind, id: Short(key.clone()) })?; + if viewed.map(|view| view.document).as_ref() != Some(&stored) { + return Err(integrity(&envelope.r#type, key, "does not view back as stored")); + } + count += 1; + } + for (type_id, key) in reader.facts.0.keys() { + if kind_of_type(type_id).is_some() && self.raw_envelope(type_id, key).is_none() { + return Err(integrity(type_id, key, "is named by a receipt but not stored")); + } + } + if count == 0 { + return Err(MindRefusal::Unavailable { detail: "the store holds no pipeline document".into() }); + } + Ok(count) } /// One typed selection over the mind, as of one snapshot. @@ -678,7 +733,6 @@ mod tests { use crate::fixtures::*; use crate::receipt::Faculty; use cultnet_rs::{Citation, FieldPredicate, Incoming, RecordRef}; - use crate::mind::schema_cache; use crate::store::test_stores::MemoryStore; use chrono::{TimeZone, Utc}; use eureka_pipeline::{ @@ -2118,4 +2172,196 @@ mod tests { assert_eq!(found.id, r(K::Question, &q1), "the id travels with the document"); assert_eq!(Some(found.document), mind.get(K::Question, &q1).unwrap()); } + + /// A store of 135 pipeline documents: past any sampling cutoff, with one + /// resolution so a view carries a closing status. Returns its rows. + fn gate_rows() -> Vec { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + for chunk in (1..=130).collect::>().chunks(10) { + committed(admit(&mut mind, chunk.iter().map(|n| question_n(*n)).collect())); + } + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + store.rows() + } + + fn read_back_of(rows: Vec) -> Result { + let store = MemoryStore::new(); + for row in rows { + store.plant(row); + } + Mind::open_with(store, &slug(INSTANCE))?.read_back() + } + + /// The stored question the faults land on: the last question in identity + /// order, with more than 100 pipeline documents before it, so a gate that + /// stops early or samples never reaches it. + fn last_question(rows: &[cultcache_rs::CultCacheEnvelope]) -> usize { + let at = rows.iter().rposition(|row| row.r#type == K::Question.type_id()).unwrap(); + let before = rows[..at].iter().filter(|row| crate::docs::kind_of_type(&row.r#type).is_some()).count(); + assert!(before > 100, "the fixture is past any cutoff: {before}"); + at + } + + fn refused_with(rows: Vec, fragment: &str, target: &str) { + match read_back_of(rows) { + Err(MindRefusal::Unavailable { detail }) => { + assert!(detail.contains(fragment), "refused for another reason: {detail}"); + assert!(detail.contains(target) || fragment == "holds no pipeline document", "the fault names the key: {detail}"); + } + other => panic!("expected an Unavailable refusal containing {fragment:?}, got {other:?}"), + } + } + + /// The gate over a clean store reads back every pipeline document, and + /// says how many: the exact count, so a gate that stops early is seen. + #[test] + fn read_back_accepts_a_clean_store_and_counts_every_document() { + assert_eq!(read_back_of(gate_rows()).unwrap(), 135); + } + + fn faulted(fault: impl FnOnce(&mut Vec, usize)) -> (Vec, String) { + let mut rows = gate_rows(); + let at = last_question(&rows); + let key = rows[at].key.clone(); + fault(&mut rows, at); + (rows, key) + } + + /// The stored question, decoded, for a test to bend. + fn stored_question(row: &cultcache_rs::CultCacheEnvelope) -> eureka_pipeline::PipelineQuestion { + match D::decode(row).unwrap() { + D::Question(question) => question, + other => panic!("not a question: {other:?}"), + } + } + + #[test] + fn read_back_refuses_an_undecodable_payload() { + let (rows, _) = faulted(|rows, at| rows[at].payload = vec![0xc1]); + assert!(matches!(read_back_of(rows), Err(MindRefusal::Document(_) | MindRefusal::Unavailable { .. }))); + } + + #[test] + fn read_back_refuses_a_field_past_its_bound() { + let (rows, key) = faulted(|rows, at| { + let mut question = stored_question(&rows[at]); + question.title = "x".repeat(300).as_str().into(); + rows[at].payload = D::Question(question).prepare(&schema_cache().unwrap()).unwrap().payload; + }); + refused_with(rows, "no longer validates", &key); + } + + /// A document stored under a key other than the one the leaf computes for + /// it, with its receipt renamed to match, so only the re-key check can see it. + #[test] + fn read_back_refuses_a_changed_key() { + let (rows, key) = faulted(|rows, at| { + let old = rows[at].key.clone(); + rows[at].key.push('x'); + let mind = Mind::open_with( + { + let store = MemoryStore::new(); + rows.iter().for_each(|row| store.plant(row.clone())); + store + }, + &slug(INSTANCE), + ) + .unwrap(); + let mut receipt = + mind.receipts().unwrap().into_iter().find(|receipt| receipt.writes.iter().any(|write| write.document_key == old)).unwrap(); + receipt.writes.iter_mut().filter(|write| write.document_key == old).for_each(|write| write.document_key.push('x')); + let renamed = schema_cache().unwrap().prepare_entry_named(&receipt.receipt_id, &receipt).unwrap().0; + let at = rows.iter().position(|row| row.r#type == renamed.r#type && row.key == renamed.key).unwrap(); + rows[at] = renamed; + }); + refused_with(rows, "key the leaf no longer computes", &key); + } + + #[test] + fn read_back_refuses_a_trailing_byte() { + let (rows, key) = faulted(|rows, at| rows[at].payload.push(0x00)); + refused_with(rows, "re-encode", &key); + } + + #[test] + fn read_back_refuses_a_payload_that_decodes_but_was_encoded_differently() { + let (rows, key) = faulted(|rows, at| { + // The same array, its header in the wider array16 form: it decodes to the + // same document and encodes to different bytes. + let payload = &rows[at].payload; + assert_eq!(payload[0] & 0xf0, 0x90, "the payload opens with a fixarray header"); + let mut wider = vec![0xdc, 0x00, payload[0] & 0x0f]; + wider.extend_from_slice(&payload[1..]); + assert_eq!(D::decode(&cultcache_rs::CultCacheEnvelope { payload: wider.clone(), ..rows[at].clone() }).unwrap(), D::Question(stored_question(&rows[at]))); + rows[at].payload = wider; + }); + refused_with(rows, "re-encode", &key); + } + + #[test] + fn read_back_refuses_a_foreign_type() { + let mut rows = gate_rows(); + let at = last_question(&rows); + rows[at].r#type = "epiphany.pipeline.finding.v9".into(); + assert!(matches!(read_back_of(rows), Err(MindRefusal::ForeignStore { .. }))); + } + + #[test] + fn read_back_refuses_a_document_stored_under_the_wrong_type() { + let (rows, _) = faulted(|rows, at| rows[at].r#type = K::Verdict.type_id().into()); + assert!(matches!(read_back_of(rows), Err(MindRefusal::Document(_) | MindRefusal::Unavailable { .. }))); + } + + /// The read path is what the gate compares the stored bytes against: a path + /// that answers nothing, or another document, is refused. + #[test] + fn read_back_refuses_a_read_path_that_does_not_return_the_stored_document() { + let mind = |rows: Vec| { + let store = MemoryStore::new(); + rows.into_iter().for_each(|row| store.plant(row)); + Mind::open_with(store, &slug(INSTANCE)).unwrap() + }; + let absent = mind(gate_rows()).read_back_through(|_, _| Ok(None)); + assert!(matches!(&absent, Err(MindRefusal::Unavailable { detail }) if detail.contains("does not view back")), "{absent:?}"); + let other = mind(gate_rows()).read_back_through(|reader, id| { + let first = reader.docs.image.iter().find(|held| held.kind == id.kind).unwrap(); + reader.view_of(first).map(Some) + }); + assert!(matches!(&other, Err(MindRefusal::Unavailable { detail }) if detail.contains("does not view back")), "{other:?}"); + } + + #[test] + fn read_back_refuses_an_empty_store() { + refused_with(Vec::new(), "holds no pipeline document", ""); + } + + #[test] + fn read_back_refuses_a_receipted_document_that_is_not_stored() { + let (rows, key) = faulted(|rows, at| { + rows.remove(at); + }); + refused_with(rows, "named by a receipt but not stored", &key); + } + + #[test] + fn read_back_refuses_a_stored_document_with_no_receipt() { + let (rows, _) = faulted(|rows, _| rows.push(prepare(&question_n(900)))); + refused_with(rows, "has no commit receipt", "Q900"); + } + + /// The upgrade gate on a real store (ruling readback-gate-and-materialized-image): + /// the one function `Mind::read_back` over a scratch copy of a state root. + /// Opens the store read-write, so run it on a copy, never the live file. + /// Measured about 30 s in debug at 11,328 documents. + #[test] + #[ignore = "needs HUGINN_MIND_SNAPSHOT, a scratch copy of a Huginn state root"] + fn stored_documents_read_back() { + let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root"); + let started = std::time::Instant::now(); + let mind = Mind::open(std::path::Path::new(&root), &Slug("eureka".into())).unwrap(); + let count = mind.read_back().unwrap(); + println!("read back {count} stored documents in {:?}", started.elapsed()); + } } diff --git a/docs/eureka-body-map.md b/docs/eureka-body-map.md index 4c6538e..f47c118 100644 --- a/docs/eureka-body-map.md +++ b/docs/eureka-body-map.md @@ -326,7 +326,7 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`. | Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) | | --- | --- | --- | --- | | Pipeline wire ids | `epiphany.pipeline..v2`, epoch `epiphany.pipeline.epoch.v2`, defined by the leaf, the Huginn crate `crates/eureka-pipeline` (lib `eureka_pipeline`), ids unchanged. | Renamed once to `eureka.pipeline..v3` by one `mind.redb` migration, at the next breaking leaf change or before the first hosted mind (ruling `wire-names-rename-before-hosting`). Adding the `run` kind or the `Mind` authority variant is additive (old stored documents decode; no migration), so it does not trigger the rename; a field added with the leaf's `= absent` marker is additive too (leaf-read-anchors r2: old bytes decode, empty fields encode byte-identically, `golden/envelopes.txt` gains lines and loses none); a field added any other way would. | Huginn owns the leaf. The migration is a live-mind upgrade, which is the operator's (`mind-rules-which-forks`). | -| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and the opt-in `stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). | +| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and huginn-mind's opt-in `query::tests::stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). | | `run` | `:run: