From 2b0cd0898b15fd9802e02a28023bf5f81483f1ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 11:40:28 +0200 Subject: [PATCH 1/4] huginn-mind: pin epiphany-pipeline to the per-kind key bound (leaf-key-bound) Moves the leaf from 73edee5cdaddece8bda9c6c3271e8ddd064337f9 to the leaf-key-bound merge commit on Epiphany main, ef9568655e75bb6cb362a038e2739a5ac5e777ee. Cargo.lock follows with that one line; no other lock line moves. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 2 +- crates/huginn-mind/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7254c78..25c9d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "epiphany-pipeline" version = "0.1.0" -source = "git+https://github.com/GameCult/Epiphany.git?rev=73edee5cdaddece8bda9c6c3271e8ddd064337f9#73edee5cdaddece8bda9c6c3271e8ddd064337f9" +source = "git+https://github.com/GameCult/Epiphany.git?rev=ef9568655e75bb6cb362a038e2739a5ac5e777ee#ef9568655e75bb6cb362a038e2739a5ac5e777ee" dependencies = [ "anyhow", "chrono", diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 883e9b0..c5f3953 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -19,7 +19,7 @@ base64 = "0.22" chrono = "0.4.44" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultcache-rs" } cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } -epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "73edee5cdaddece8bda9c6c3271e8ddd064337f9", package = "epiphany-pipeline" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "ef9568655e75bb6cb362a038e2739a5ac5e777ee", package = "epiphany-pipeline" } rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } From 790b6cd4f68de70a37f9a537b5dc44de4883d27b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 11:40:29 +0200 Subject: [PATCH 2/4] huginn-mind: refuse an unkeyable document as a document, and prove the real mind reads back Admission wrapped every prepare error in Unavailable, so a key the leaf could not compose reached the client as an unavailable organ. One private prepare(document, cache) now keys through the leaf first, mapped to MindRefusal::Document, then encodes; Mind::admit and the derived-write loop both use it. Unavailable stays for store, cache-encode and decode faults. Tests: an_unkeyable_document_is_refused_as_a_document (store and receipts unchanged), prepare_classifies_a_key_refusal_as_a_document_and_encodes_the_rest (the derived path cannot reach an unkeyable resolution after the per-kind bound, so the helper is pinned directly), and the #[ignore] snapshot_admits_and_reads_back, which opens a scratch copy of the live mind named by HUGINN_MIND_SNAPSHOT, views every stored document back unchanged, resolves the six stuck findings and withdraws one long-keyed resolution. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 65 +++++++++++++++++++++++++-- crates/huginn-mind/src/mind.rs | 68 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 6443e94..487465d 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -29,7 +29,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; -use cultcache_rs::CultCacheEnvelope; +use cultcache_rs::{CultCache, CultCacheEnvelope}; use epiphany_pipeline::{ ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, @@ -67,6 +67,14 @@ pub enum PipelineAdmissionOutcome { Conflict { identities: Vec }, } +/// Keys a document through the leaf, then encodes it. A key the leaf cannot +/// compose is the document's fault and is refused as a document; only the +/// cache's encode fault is the organ's. +fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result { + pipeline_key(document).map_err(MindRefusal::Document)?; + document.prepare(cache).map_err(unavailable) +} + impl Mind { /// Validates and prepares every document through the leaf, then admits /// the envelopes. @@ -76,9 +84,9 @@ impl Mind { if let Err(refusal) = document.validate() { return PipelineAdmissionOutcome::Refused(MindRefusal::Document(refusal)); } - match document.prepare(self.cache()) { + match prepare(document, self.cache()) { Ok(envelope) => envelopes.push(envelope), - Err(error) => return PipelineAdmissionOutcome::Refused(unavailable(error)), + Err(refusal) => return PipelineAdmissionOutcome::Refused(refusal), } } self.admit_prepared(&batch.instance, batch.provenance, envelopes, now) @@ -133,7 +141,7 @@ impl Mind { let originals = docs.batch.len(); for document in derive(&docs, &mind) { document.validate()?; - let envelope = document.prepare(self.cache()).map_err(unavailable)?; + let envelope = prepare(&document, self.cache())?; docs.push(stage(envelope, &mind)?)?; } for staged in &docs.batch[originals..] { @@ -684,6 +692,8 @@ fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { mod tests { use super::*; use crate::fixtures::*; + use crate::fixtures::prepare; + use crate::mind::schema_cache; use crate::receipt::{DocumentVersion, Faculty, HuginnCommitReceipt}; use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; use cultcache_rs::DatabaseEntry; @@ -1671,6 +1681,53 @@ mod tests { ); } + /// refusals-typed: a document the leaf cannot key is the client's fault. + /// The finding's local is 64 bytes of label under `cut-1.s1.`; the + /// resolution's subject is a resolution whose own local leaves no room for + /// a third nesting. Both pass `validate` and fail only at the key. + fn unkeyable() -> Vec { + let wide = D::Finding(finding("1", 1, &"w".repeat(64), FindingConfidence::Confirmed)); + let deep_subject = id("resolution", &format!("{}.{}", "x".repeat(60), "y".repeat(40))); + vec![wide, resolution(r(K::Resolution, &deep_subject), withdrawn())] + } + + #[test] + fn an_unkeyable_document_is_refused_as_a_document() { + let store = RefusingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let receipts = receipt_count(&mind); + for document in unkeyable() { + document.validate().unwrap(); + match refusal(admit(&mut mind, vec![document])) { + MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) => { + assert!(field.ends_with(".key"), "{field}") + } + other => panic!("{other:?}"), + } + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + } + } + + /// The derived path (`admit_steps`) shares the helper. No derivable + /// document can fail to key after the leaf's per-kind bound (a derived + /// resolution is depth one or two over a subject of at most 64 bytes), so + /// the helper is pinned directly. + #[test] + fn prepare_classifies_a_key_refusal_as_a_document_and_encodes_the_rest() { + let cache = schema_cache().unwrap(); + let keyable = question("Q1", &["A", "B"], "A"); + assert_eq!(super::prepare(&keyable, &cache).unwrap(), prepare(&keyable)); + for document in unkeyable() { + assert!(matches!( + super::prepare(&document, &cache), + Err(MindRefusal::Document(PipelineRefusal::InvalidFormat { .. })) + )); + } + } + #[test] fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() { let mut mind = seeded(); diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index ff5fbc2..f89a1af 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -355,6 +355,7 @@ 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; @@ -778,4 +779,71 @@ 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, committed, r, resolution, withdrawn}; + use epiphany_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(); + + 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(); + 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, epiphany_pipeline::ResolutionOutcome::Withdrawn { .. }) + && !envelope.key.contains(":resolution:resolution.") + { + resolutions.push(envelope.key.clone()); + } + viewed += 1; + } + assert!(viewed > 0, "the snapshot held no pipeline document"); + println!("viewed {viewed} stored documents unchanged"); + + for local in [ + "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 subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}")); + let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]); + let (_, writes) = committed(outcome); + assert_eq!(writes.len(), 1, "{local}"); + } + + let mut tried = 0; + let mut withdrew = None; + for key in resolutions { + let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn()); + let withdrawal_key = pipeline_key(&withdrawal).unwrap(); + if withdrawal_key.rsplit(':').next().unwrap().len() <= 64 { + continue; + } + tried += 1; + if let PipelineAdmissionOutcome::Committed { .. } = admit(&mut mind, vec![withdrawal]) { + withdrew = Some(withdrawal_key); + break; + } + } + let withdrew = withdrew.unwrap_or_else(|| panic!("no stored resolution with a withdrawal key over 64 bytes withdrew; tried {tried}")); + println!("withdrew a resolution under a {}-byte key", withdrew.len()); + } } From f29a7ce7f0bc3977e2adb625cb09ec6b28646d4b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:10:19 +0200 Subject: [PATCH 3/4] huginn-mind: tighten the refusal and read-back tests (Soul s1 findings) snapshot_admits_and_reads_back now counts pipeline envelopes apart from the loop, asserts validate() and pipeline_key == stored key for every one, admits a resolution for every stuck finding and a withdrawal for every unwithdrawable resolution, and fails with the id named on any refusal. The refusal tests assert the exact leaf refusal (field and value from pipeline_key) at Mind::admit and at the helper, and eureka-state gains a tool-level test that the typed refusal reaches the result intact. Tests only; no production code changes. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/mcp.rs | 34 +++++++++++++++- crates/huginn-mind/src/admission.rs | 22 +++++------ crates/huginn-mind/src/mind.rs | 60 ++++++++++++++++++++--------- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 27ee9fd..969ad55 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -11,7 +11,7 @@ use std::time::Duration; use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; use huginn_mind::{HuginnMindRequest, HuginnMindResponse}; -use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short}; +use huginn_mind::epiphany_pipeline::{Date, PipelineDocument, PipelineKind, PipelineRef, Short}; use serde_json::{Value, json}; mod common; @@ -530,3 +530,35 @@ fn initialize_advertises_tools() { let mcp = Mcp::at(INSTANCE, closed_port()); assert!(mcp.init["capabilities"]["tools"].is_object(), "{}", mcp.init); } + +/// A document the leaf cannot key is the client's fault, and the tool result +/// carries the leaf's own refusal: the same field and the same offending +/// value `pipeline_key` computes for that document, never a re-built one. +#[test] +fn admit_carries_the_leaf_key_refusal_with_its_field_and_value() { + use huginn_mind::epiphany_pipeline::{PipelineResolution, ResolutionOutcome, pipeline_key}; + + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + // A resolution of a resolution of a resolution: it validates and has no + // room left in the key for a third nesting. + let subject = format!("{CAMPAIGN}:resolution:{}.{}", "x".repeat(60), "y".repeat(40)); + let document = PipelineDocument::Resolution(PipelineResolution { + subject: PipelineRef { kind: PipelineKind::Resolution, id: Short(subject) }, + sequence: 1, + outcome: ResolutionOutcome::Withdrawn { reason: "moot".into() }, + rationale: "Resolved.".into(), + resolved_on: Date("2026-09-29".into()), + }); + document.validate().unwrap(); + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + let expected = serde_json::to_value(&leaf).unwrap(); + assert_eq!(expected["InvalidFormat"]["field"], json!("resolution.key")); + + let (error, refused) = mcp.call("admit", faculty_args(vec![serde_json::to_value(&document).unwrap()])); + assert!(!error, "{refused}"); + assert_eq!(refused["Refused"]["Document"], expected, "{refused}"); + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(2), "only the seed is committed: {whoami}"); +} diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 487465d..f2baeea 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -1698,14 +1698,16 @@ mod tests { seed(&mut mind); let before = store.rows(); let receipts = receipt_count(&mind); - for document in unkeyable() { + for (document, field) in unkeyable().into_iter().zip(["finding.key", "resolution.key"]) { document.validate().unwrap(); - match refusal(admit(&mut mind, vec![document])) { - MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) => { - assert!(field.ends_with(".key"), "{field}") - } - other => panic!("{other:?}"), - } + // The leaf owns what a key error says; admission must carry it + // unchanged, field and offending value included. + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + assert!( + matches!(&leaf, PipelineRefusal::InvalidFormat { field: named, value } if named == field && !value.is_empty()), + "{leaf:?}" + ); + assert_eq!(refusal(admit(&mut mind, vec![document])), MindRefusal::Document(leaf), "{field}"); assert_eq!(store.rows(), before); assert_eq!(receipt_count(&mind), receipts); } @@ -1721,10 +1723,8 @@ mod tests { let keyable = question("Q1", &["A", "B"], "A"); assert_eq!(super::prepare(&keyable, &cache).unwrap(), prepare(&keyable)); for document in unkeyable() { - assert!(matches!( - super::prepare(&document, &cache), - Err(MindRefusal::Document(PipelineRefusal::InvalidFormat { .. })) - )); + let leaf = pipeline_key(&document).expect_err("the fixture must not key"); + assert_eq!(super::prepare(&document, &cache).unwrap_err(), MindRefusal::Document(leaf)); } } diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index f89a1af..4287f5f 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -788,17 +788,27 @@ mod tests { #[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, committed, r, resolution, withdrawn}; + use crate::fixtures::{admit, r, resolution, withdrawn}; use epiphany_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() + .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() @@ -812,38 +822,50 @@ mod tests { } viewed += 1; } - assert!(viewed > 0, "the snapshot held no pipeline document"); - println!("viewed {viewed} stored documents unchanged"); + assert_eq!(viewed, pipeline_envelopes, "every pipeline envelope validates, rekeys and views back"); + println!("validated, rekeyed and viewed {viewed} stored documents unchanged"); - for local in [ + 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() })]); - let (_, writes) = committed(outcome); - assert_eq!(writes.len(), 1, "{local}"); + match outcome { + PipelineAdmissionOutcome::Committed { writes, .. } => { + assert_eq!(writes.len(), 1, "{local}"); + resolved += 1; + } + other => panic!("stuck finding {local} was not resolved: {other:?}"), + } } - let mut tried = 0; - let mut withdrew = None; + 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(); - if withdrawal_key.rsplit(':').next().unwrap().len() <= 64 { - continue; - } - tried += 1; - if let PipelineAdmissionOutcome::Committed { .. } = admit(&mut mind, vec![withdrawal]) { - withdrew = Some(withdrawal_key); - break; + 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)); } } - let withdrew = withdrew.unwrap_or_else(|| panic!("no stored resolution with a withdrawal key over 64 bytes withdrew; tried {tried}")); - println!("withdrew a resolution under a {}-byte key", withdrew.len()); + 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"); } } From 0f491b00ef971b49f024d4341c8c309065871772 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:12:36 +0200 Subject: [PATCH 4/4] huginn-mind: iterate the envelope slice in the read-back census Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/mind.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 4287f5f..619471e 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -797,6 +797,7 @@ mod tests { // 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");