Merge eureka-substrate huginn-pin-and-refusals: unkeyable documents are refused as documents

Eureka eureka-substrate cut huginn-pin-and-refusals. Soul verdicts s1
(two findings, fixed by 0f491b00) and s2 (holds, no findings). Pins
epiphany-pipeline to the per-kind key bound; a pipeline_key error is
MindRefusal::Document, not Unavailable; the snapshot read-back test
re-keys all 244 stored documents and resolves every stuck subject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-01 12:48:49 +02:00
commit b85cd5d7d5
5 changed files with 187 additions and 7 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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}");
}

View file

@ -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"] }

View file

@ -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<PipelineRef> },
}
/// 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<CultCacheEnvelope, MindRefusal> {
pipeline_key(document).map_err(MindRefusal::Document)?;
document.prepare(cache).map_err(unavailable)
}
impl<S: MindStore> Mind<S> {
/// Validates and prepares every document through the leaf, then admits
/// the envelopes.
@ -76,9 +84,9 @@ impl<S: MindStore> Mind<S> {
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<S: MindStore> Mind<S> {
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<D> {
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, field) in unkeyable().into_iter().zip(["finding.key", "resolution.key"]) {
document.validate().unwrap();
// 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);
}
}
/// 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() {
let leaf = pipeline_key(&document).expect_err("the fixture must not key");
assert_eq!(super::prepare(&document, &cache).unwrap_err(), MindRefusal::Document(leaf));
}
}
#[test]
fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() {
let mut mind = seeded();

View file

@ -355,6 +355,7 @@ fn snapshot(cache: &CultCache) -> Vec<CultCacheEnvelope> {
#[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,94 @@ 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 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()
.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, epiphany_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");
}
}