From 8041d82fe1d65fbdbb05d9080be217c5f2e56108 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:36:18 +0200 Subject: [PATCH 001/491] eureka-state tests: the in-process daemon fixtures move to tests/common The daemon half of the client's fixtures (an in-process daemon over a temporary mind on a loopback port, and the scripted server) becomes tests/common/mod.rs, so the MCP binary's tests (Cut 13b) drive the same daemon instead of a second copy of it. No behaviour change; client.rs shrinks by the moved lines. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/client.rs | 153 +-------------------- crates/eureka-state/tests/common/mod.rs | 174 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 150 deletions(-) create mode 100644 crates/eureka-state/tests/common/mod.rs diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 07f458a..1ef33e8 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -28,29 +28,12 @@ use huginn_mind::{ }; use tempfile::TempDir; -const INSTANCE: &str = "eureka"; +mod common; +use common::*; + const OTHER: &str = "thought-cage"; -const CAMPAIGN: &str = "eureka-state"; -const REPO: &str = "GameCult/Epiphany"; const TIMEOUT: Duration = Duration::from_secs(20); -fn slug(value: &str) -> Slug { - Slug(value.into()) -} - -fn sha() -> Sha { - Sha("5f98228d".into()) -} - -fn identity(name: &str) -> PipelineDocument { - PipelineDocument::Instance(PipelineInstance { - instance: slug(name), - display_name: Short(format!("{name} mind")), - created_at: Date("2026-09-29".into()), - host: Short(name.into()), - }) -} - fn stewardship() -> PipelineDocument { PipelineDocument::Stewardship(PipelineStewardship { instance: slug(INSTANCE), @@ -61,29 +44,6 @@ fn stewardship() -> PipelineDocument { }) } -fn campaign() -> PipelineDocument { - PipelineDocument::Campaign(PipelineCampaign { - slug: slug(CAMPAIGN), - title: Title("Eureka pipeline state".into()), - repos: vec![OrgRepo(REPO.into())], - working_branch: Short("hands/cut13a".into()), - target_doc: DocRef { path: Short("notes/target.md".into()), start_line: 1, end_line: 9, commit: sha() }, - }) -} - -fn batch(instance: &str, documents: Vec) -> PipelineAdmissionBatch { - PipelineAdmissionBatch { - instance: slug(instance), - provenance: PipelineProvenance { - faculty: Faculty::Hands, - agent: Short("claude".into()), - session: Short("session-1".into()), - tool: Short("admit".into()), - }, - documents, - } -} - /// A `Line` and a `Short` at the leaf's own bound. fn line(n: usize) -> Line { Line(format!("{n:0>1000}")) @@ -151,108 +111,6 @@ fn cut_ref(cut: &str) -> PipelineRef { PipelineRef { kind: PipelineKind::CutSpec, id: Short(format!("{CAMPAIGN}:cut_spec:cut-{cut}.r1")) } } -/// No index: the client's subject is the transport, not the projection. -struct Inert; - -impl IndexSink for Inert { - fn committed(&mut self, _mind: &Mind, _writes: &[PipelineRef]) -> anyhow::Result<()> { - Ok(()) - } - fn status(&self) -> IndexStatus { - IndexStatus::Current - } - fn search(&mut self, _text: &str, _top_k: u32) -> Result { - Err("this index cannot search".into()) - } - fn search_deadline(&self) -> Duration { - Duration::ZERO - } - fn searched(&mut self) -> Vec<(SearchTicket, Result)> { - Vec::new() - } - fn abandon(&mut self, _ticket: SearchTicket) {} -} - -type TestDaemon = Daemon; - -fn answered(handled: Handled) -> HuginnMindResponse { - match handled { - Handled::Answered(response) => response, - Handled::Searching(_) => panic!("the inert index cannot search"), - } -} - -/// A daemon over a temporary mind whose identity is admitted, and whatever -/// else `seed` admits. -fn mind(seed: Vec>) -> (TempDir, TestDaemon) { - let root = tempfile::tempdir().unwrap(); - let mut daemon = Daemon::new(Mind::open(root.path(), &slug(INSTANCE)).unwrap(), Inert); - for documents in std::iter::once(vec![identity(INSTANCE)]).chain(seed) { - let outcome = answered(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), Utc::now())); - assert!(matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), "{outcome:?}"); - } - (root, daemon) -} - -/// A server on a loopback port that stops when dropped. -struct Server { - addr: SocketAddr, - stopping: Arc, - thread: Option>, - /// The sessions a scripted server has seen end: what the client's - /// disconnect looks like from the daemon's side. - ended: Arc, - _root: Option, -} - -impl Drop for Server { - fn drop(&mut self) { - self.stopping.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -fn serve(root: TempDir, mut daemon: TestDaemon) -> Server { - let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); - let addr = hub.local_addr().unwrap(); - let stopping = Arc::new(AtomicBool::new(false)); - let loop_stopping = Arc::clone(&stopping); - let registry = schema_registry().unwrap(); - let thread = std::thread::spawn(move || { - run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()).unwrap(); - }); - Server { addr, stopping, thread: Some(thread), ended: Arc::default(), _root: Some(root) } -} - -/// A server that answers each schema message with whatever `script` says. -fn scripted(mut script: impl FnMut(&CultNetMessage) -> CultNetMessage + Send + 'static) -> Server { - let mut hub = bind("127.0.0.1:0".parse().unwrap(), "scripted").unwrap(); - let addr = hub.local_addr().unwrap(); - let stopping = Arc::new(AtomicBool::new(false)); - let loop_stopping = Arc::clone(&stopping); - let ended = Arc::new(AtomicUsize::new(0)); - let loop_ended = Arc::clone(&ended); - let thread = std::thread::spawn(move || { - while !loop_stopping.load(Ordering::Relaxed) { - hub.poll_resends().unwrap(); - while let Some(event) = hub.receive_event_once().unwrap() { - if matches!(event, CultNetRudpServerEvent::Disconnected { .. }) { - loop_ended.fetch_add(1, Ordering::Relaxed); - } - let CultNetRudpServerEvent::Frame { session, frame } = event else { continue }; - let message = - decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0).unwrap(); - hub.send_schema_message(&session, &script(&message)).unwrap(); - } - std::thread::sleep(Duration::from_millis(2)); - } - }); - Server { addr, stopping, thread: Some(thread), ended, _root: None } -} - -/// A scripted server that answers any operation with a deferral of `body` and /// serves its chunks faithfully. fn deferring(body: Vec) -> Server { deferring_in_chunks(body, 256 * 1024, Duration::ZERO) @@ -298,11 +156,6 @@ fn rejected(error: ClientError) -> (SocketAddr, String, String) { } } -/// A scripted server whose every answer is `reply`. -fn answering(reply: CultNetMessage) -> Server { - scripted(move |_| reply.clone()) -} - fn nothing_found(message_id: &str, operation: &str) -> CultNetMessage { encode_response(message_id, operation, &HuginnMindResponse::View(None), "scripted").unwrap() } diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs new file mode 100644 index 0000000..5729e68 --- /dev/null +++ b/crates/eureka-state/tests/common/mod.rs @@ -0,0 +1,174 @@ +//! The daemon half of the fixtures: an in-process Huginn daemon over a +//! temporary mind, on a loopback port, shared by the client and the server +//! tests. +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use chrono::Utc; +use cultnet_rs::{CultNetMessage, CultNetRudpServerEvent, CultNetWireContract, decode_cultnet_message_from_slice}; +use huginn_daemon::serve::{bind, run, schema_registry}; +use huginn_daemon::{Daemon, Handled, Hits, IndexSink, SearchTicket, ServeOptions}; +use huginn_mind::epiphany_pipeline::{ + Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, Sha, Short, Slug, Title, +}; +use huginn_mind::{ + Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindStore, OwnedRedbMessagePackBackingStore, + PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance, +}; +use tempfile::TempDir; + +pub const INSTANCE: &str = "eureka"; + +pub fn slug(value: &str) -> Slug { + Slug(value.into()) +} + +pub fn identity(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: Short(format!("{name} mind")), + created_at: Date("2026-09-29".into()), + host: Short(name.into()), + }) +} + +pub fn batch(instance: &str, documents: Vec) -> PipelineAdmissionBatch { + PipelineAdmissionBatch { + instance: slug(instance), + provenance: PipelineProvenance { + faculty: Faculty::Hands, + agent: Short("claude".into()), + session: Short("session-1".into()), + tool: Short("admit".into()), + }, + documents, + } +} + +/// No index: the client's subject is the transport, not the projection. +pub struct Inert; + +impl IndexSink for Inert { + fn committed(&mut self, _mind: &Mind, _writes: &[PipelineRef]) -> anyhow::Result<()> { + Ok(()) + } + fn status(&self) -> IndexStatus { + IndexStatus::Current + } + fn search(&mut self, _text: &str, _top_k: u32) -> Result { + Err("this index cannot search".into()) + } + fn search_deadline(&self) -> Duration { + Duration::ZERO + } + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } + fn abandon(&mut self, _ticket: SearchTicket) {} +} + +pub type TestDaemon = Daemon; + +pub fn answered(handled: Handled) -> HuginnMindResponse { + match handled { + Handled::Answered(response) => response, + Handled::Searching(_) => panic!("the inert index cannot search"), + } +} + +/// A daemon over a temporary mind whose identity is admitted, and whatever +/// else `seed` admits. +pub fn mind(seed: Vec>) -> (TempDir, TestDaemon) { + let root = tempfile::tempdir().unwrap(); + let mut daemon = Daemon::new(Mind::open(root.path(), &slug(INSTANCE)).unwrap(), Inert); + for documents in std::iter::once(vec![identity(INSTANCE)]).chain(seed) { + let outcome = answered(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), Utc::now())); + assert!(matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), "{outcome:?}"); + } + (root, daemon) +} + +/// A server on a loopback port that stops when dropped. +pub struct Server { + pub addr: SocketAddr, + pub stopping: Arc, + pub thread: Option>, + /// The sessions a scripted server has seen end: what the client's + /// disconnect looks like from the daemon's side. + pub ended: Arc, + pub _root: Option, +} + +impl Drop for Server { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub fn serve(root: TempDir, mut daemon: TestDaemon) -> Server { + let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); + let addr = hub.local_addr().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let registry = schema_registry().unwrap(); + let thread = std::thread::spawn(move || { + run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()).unwrap(); + }); + Server { addr, stopping, thread: Some(thread), ended: Arc::default(), _root: Some(root) } +} + +/// A server that answers each schema message with whatever `script` says. +pub fn scripted(mut script: impl FnMut(&CultNetMessage) -> CultNetMessage + Send + 'static) -> Server { + let mut hub = bind("127.0.0.1:0".parse().unwrap(), "scripted").unwrap(); + let addr = hub.local_addr().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let ended = Arc::new(AtomicUsize::new(0)); + let loop_ended = Arc::clone(&ended); + let thread = std::thread::spawn(move || { + while !loop_stopping.load(Ordering::Relaxed) { + hub.poll_resends().unwrap(); + while let Some(event) = hub.receive_event_once().unwrap() { + if matches!(event, CultNetRudpServerEvent::Disconnected { .. }) { + loop_ended.fetch_add(1, Ordering::Relaxed); + } + let CultNetRudpServerEvent::Frame { session, frame } = event else { continue }; + let message = + decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0).unwrap(); + hub.send_schema_message(&session, &script(&message)).unwrap(); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + Server { addr, stopping, thread: Some(thread), ended, _root: None } +} + +/// A scripted server whose every answer is `reply`. +pub fn answering(reply: CultNetMessage) -> Server { + scripted(move |_| reply.clone()) +} + +pub const CAMPAIGN: &str = "eureka-state"; +pub const REPO: &str = "GameCult/Epiphany"; + +pub fn sha() -> Sha { + Sha("5f98228d".into()) +} + +pub fn campaign() -> PipelineDocument { + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: Title("Eureka pipeline state".into()), + repos: vec![OrgRepo(REPO.into())], + working_branch: Short("hands/cut13a".into()), + target_doc: DocRef { path: Short("notes/target.md".into()), start_line: 1, end_line: 9, commit: sha() }, + }) +} From e0f07c3f53cbf9496bf12b3235832a642a27c3ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:36:33 +0200 Subject: [PATCH 002/491] eureka-state: the MCP server, four tools 1:1 with the wire The eureka-state binary serves whoami, admit, view and query over stdio (rmcp 2.2.0), each one HuginnClient call. EUREKA_INSTANCE and HUGINN_ENDPOINT are process configuration: the server fills in the instance and provenance.tool, so input can name neither. Bad configuration never stops startup; whoami reports it and every other tool refuses with the typed body. A mind's refusal is a normal result; Unavailable, Rejected and Misconfigured are results with isError. The query input schema bundles CultLib's selection schema into $defs (vendored; a test pins it to the cultnet-rs revision). Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 254 ++++++++++++ crates/eureka-state/Cargo.toml | 15 +- .../schemas/cultnet.selection.schema.json | 100 +++++ crates/eureka-state/src/main.rs | 260 +++++++++++++ crates/eureka-state/tests/mcp.rs | 366 ++++++++++++++++++ 5 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 crates/eureka-state/schemas/cultnet.selection.schema.json create mode 100644 crates/eureka-state/src/main.rs create mode 100644 crates/eureka-state/tests/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index f278655..71de095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -258,6 +269,40 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + [[package]] name = "der" version = "0.7.10" @@ -341,8 +386,13 @@ dependencies = [ "cultnet-rs", "huginn-daemon", "huginn-mind", + "rmcp", "rmp-serde", + "schemars", + "serde", + "serde_json", "tempfile", + "tokio", ] [[package]] @@ -373,12 +423,71 @@ dependencies = [ "winapi", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -391,8 +500,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -536,6 +650,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "inout" version = "0.1.4" @@ -607,6 +727,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -747,6 +873,40 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "rmp" version = "0.8.15" @@ -811,6 +971,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive", @@ -972,6 +1133,12 @@ dependencies = [ "der", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1013,6 +1180,93 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml index 0de0909..be76e34 100644 --- a/crates/eureka-state/Cargo.toml +++ b/crates/eureka-state/Cargo.toml @@ -5,18 +5,27 @@ edition.workspace = true license.workspace = true publish.workspace = true -# The client core: one call over CultNet to a Huginn daemon. It owns the -# transport and nothing of the mind. The operation envelope, the wire +# The client core (`lib.rs`): one call over CultNet to a Huginn daemon. It owns +# the transport and nothing of the mind. The operation envelope, the wire # vocabulary and the deferred-body bound are `huginn-mind`'s, so the client and # the daemon cannot spell them differently; the content fetch and its # verification are `cultnet-rs`'s. +# +# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one +# consumer: four tools over stdio, one per wire operation, holding no state. [dependencies] anyhow = "1" cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "6251cbcd37c12ef3f0c1cc50063a03369225b7ac", package = "cultnet-rs" } huginn-mind = { path = "../huginn-mind" } +rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] } rmp-serde = "1" +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "macros", "io-std"] } -# Tests run a real daemon in-process against a temporary mind. +# Tests run a real daemon in-process against a temporary mind, and the real +# binary against it. [dev-dependencies] chrono = "0.4.44" huginn-daemon = { path = "../huginn-daemon" } diff --git a/crates/eureka-state/schemas/cultnet.selection.schema.json b/crates/eureka-state/schemas/cultnet.selection.schema.json new file mode 100644 index 0000000..7076428 --- /dev/null +++ b/crates/eureka-state/schemas/cultnet.selection.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json", + "title": "CultNet Typed Selection", + "type": "object", + "additionalProperties": false, + "properties": { + "schemas": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead." + }, + "keys": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead." + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/fieldPredicate" } + }, + "cites": { "$ref": "#/$defs/citation" }, + "cited": { "$ref": "#/$defs/incoming" }, + "projection": { + "type": "string", + "enum": ["header", "document"], + "default": "header" + }, + "descending": { "type": "boolean", "default": false }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)." + }, + "cursor": { "type": "string" } + }, + "$defs": { + "recordRef": { + "type": "object", + "required": ["schemaId", "recordKey"], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 } + } + }, + "fieldPredicate": { + "type": "object", + "required": ["index", "op"], + "additionalProperties": false, + "properties": { + "index": { "type": "string", "minLength": 1 }, + "op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] }, + "values": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "number": { + "type": "string", + "description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.", + "pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$" + } + }, + "oneOf": [ + { + "required": ["values"], + "not": { "required": ["number"] }, + "properties": { "op": { "const": "any_of" } } + }, + { + "required": ["number"], + "not": { "required": ["values"] }, + "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } + } + ] + }, + "citation": { + "type": "object", + "required": ["target"], + "additionalProperties": false, + "properties": { + "target": { "$ref": "#/$defs/recordRef" }, + "role": { "type": "string", "minLength": 1 } + } + }, + "incoming": { + "type": "object", + "required": ["role", "exists"], + "additionalProperties": false, + "properties": { + "role": { "type": "string", "minLength": 1 }, + "exists": { "type": "boolean" } + } + } + } +} diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs new file mode 100644 index 0000000..a7f4a58 --- /dev/null +++ b/crates/eureka-state/src/main.rs @@ -0,0 +1,260 @@ +//! `eureka-state`: Huginn's mind as four MCP tools over stdio. +//! +//! One tool per wire operation (`whoami`, `admit`, `view`, `query`), each a +//! `HuginnClient` call and nothing else. The declared identity is this +//! process's configuration, not a per-call argument: `EUREKA_INSTANCE` names +//! the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that holds +//! it. The server fills in `instance` and `provenance.tool`, so a caller can +//! name neither. +//! +//! Bad configuration does not stop the server: an MCP server that dies at +//! startup vanishes from Claude Code without a word. `whoami` reports it and +//! every other tool refuses with it. A refusal from the mind is an answer and +//! comes back as a normal result; a daemon that cannot be reached or that +//! rejects the envelope is a result with `isError: true`, typed, so a caller +//! can tell a down daemon from a permanent mismatch. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use cultnet_rs::Selection; +use eureka_state::{ClientError, HuginnClient}; +use huginn_mind::epiphany_pipeline::{PipelineDocument, PipelineRef, Short, Slug}; +use huginn_mind::{ + Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineProvenance, SemanticQuery, +}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; +use rmcp::{ServerHandler, ServiceExt, tool, tool_handler, tool_router}; +use schemars::{JsonSchema, Schema, SchemaGenerator}; +use serde::Deserialize; +use serde_json::{Value, json}; + +/// The `provenance.tool` every admission from this server carries. +const TOOL: &str = "eureka-state"; +/// How long one call may take, connection to last byte. +const TIMEOUT: Duration = Duration::from_secs(15); + +/// CultLib's published shape of a selection, vendored because the selection +/// type carries no schema of its own. `selection_schema_matches_cultlib` pins +/// it to the CultLib revision `cultnet-rs` is built from. +const SELECTION_SCHEMA: &str = include_str!("../schemas/cultnet.selection.schema.json"); + +/// The selection's schema, self-contained: its own `$defs` move into the +/// tool's, so every `$ref` in the input schema is `#/$defs/...`. +fn selection_schema(generator: &mut SchemaGenerator) -> Schema { + let Ok(Value::Object(mut selection)) = serde_json::from_str(SELECTION_SCHEMA) else { + unreachable!("the vendored selection schema is a JSON object"); + }; + selection.remove("$schema"); + selection.remove("$id"); + if let Some(Value::Object(defs)) = selection.remove("$defs") { + generator.definitions_mut().extend(defs); + } + Schema::try_from(Value::Object(selection)).expect("an object is a schema") +} + +#[derive(Deserialize, JsonSchema)] +struct AdmitInput { + /// The faculty the batch is admitted as. Attribution, not authority. + faculty: Faculty, + /// The admitting agent's name. + agent: Short, + /// The admitting session, recorded on the receipt so that several sessions + /// sharing one mind stay tellable apart. + session: Short, + /// The documents to admit as one batch. The mind admits them whole or refuses them. + documents: Vec, +} + +#[derive(Deserialize, JsonSchema)] +struct ViewInput { + /// The document's id, as a query or an admission returned it. + id: PipelineRef, +} + +#[derive(Deserialize, JsonSchema)] +struct QueryInput { + /// A CultNet typed selection over the mind's documents. + #[schemars(schema_with = "selection_schema")] + selection: Selection, + /// Optional: rank by nearness to this text instead of ordering by key. + semantic: Option, +} + +/// Why a call produced no answer. Each is a typed body with `isError: true`. +enum Trouble { + Misconfigured(String), + Client(ClientError), + Internal(String), +} + +impl Trouble { + fn body(&self) -> Value { + match self { + Self::Misconfigured(detail) => json!({ "error": "Misconfigured", "detail": detail }), + Self::Client(ClientError::Unavailable { endpoint, detail }) => { + json!({ "error": "Unavailable", "endpoint": format!("rudp://{endpoint}"), "detail": detail }) + } + Self::Client(ClientError::Rejected { endpoint, code, detail }) => { + json!({ "error": "Rejected", "endpoint": format!("rudp://{endpoint}"), "code": code, "detail": detail }) + } + Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), + } + } +} + +/// The two environment values as given, and the client they make when both are +/// valid. +struct Settings { + instance: Option, + endpoint: Option, + client: Result, +} + +impl Settings { + fn from_env() -> Self { + let instance = std::env::var("EUREKA_INSTANCE").ok(); + let endpoint = std::env::var("HUGINN_ENDPOINT").ok(); + let client = client(instance.as_deref(), endpoint.as_deref()); + Self { instance, endpoint, client } + } +} + +fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result { + let instance = instance.ok_or("EUREKA_INSTANCE is not set")?; + let slug = Slug(instance.to_string()); + slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?; + let endpoint = endpoint.ok_or("HUGINN_ENDPOINT is not set")?; + let address = endpoint + .strip_prefix("rudp://") + .and_then(|address| address.parse::().ok()) + .ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://:: {endpoint}"))?; + Ok(HuginnClient::new(address, slug, TIMEOUT)) +} + +#[derive(Clone)] +struct EurekaState { + settings: Arc, + tool_router: ToolRouter, +} + +impl EurekaState { + fn new(settings: Settings) -> Self { + Self { settings: Arc::new(settings), tool_router: Self::tool_router() } + } + + /// One wire call for the configured instance. The request is built from + /// that instance, never from anything the caller sent. + async fn call(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> Result { + let client = self.settings.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))?.clone(); + let request = request(client.instance().clone()); + tokio::task::spawn_blocking(move || client.call(request)) + .await + .map_err(|error| Trouble::Internal(error.to_string()))? + .map_err(Trouble::Client) + } + + async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> CallToolResult { + match self.call(request).await { + Ok(response) => reply(payload(response), false), + Err(trouble) => reply(trouble.body(), true), + } + } +} + +/// The answer as the tool's output: the operation's own payload, or the typed +/// refusal, tagged, when the mind refused the request. +fn payload(response: HuginnMindResponse) -> Value { + let value = match response { + HuginnMindResponse::Whoami(status) => serde_json::to_value(status), + HuginnMindResponse::Admit(outcome) => serde_json::to_value(outcome), + HuginnMindResponse::View(view) => serde_json::to_value(view), + HuginnMindResponse::Query(page) => serde_json::to_value(page), + other => serde_json::to_value(other), + }; + value.expect("wire types serialize") +} + +fn reply(body: Value, is_error: bool) -> CallToolResult { + let content = vec![ContentBlock::text(body.to_string())]; + if is_error { CallToolResult::error(content) } else { CallToolResult::success(content) } +} + +#[tool_router] +impl EurekaState { + #[tool( + description = "Who this server acts for and whether the Huginn daemon answers: the configured instance and endpoint, reachable, and the mind's status when it does. Reports bad configuration instead of failing." + )] + async fn whoami(&self) -> CallToolResult { + let mut out = json!({ + "instance": self.settings.instance, + "endpoint": self.settings.endpoint, + "reachable": false, + "status": null, + }); + match self.call(|_| HuginnMindRequest::Whoami).await { + Ok(HuginnMindResponse::Whoami(status)) => { + out["reachable"] = json!(true); + out["status"] = serde_json::to_value(status).expect("wire types serialize"); + } + Ok(other) => out["error"] = json!({ "error": "Internal", "detail": format!("unexpected answer: {other:?}") }), + Err(trouble) => out["error"] = trouble.body(), + } + reply(out, false) + } + + #[tool( + description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one." + )] + async fn admit(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| { + HuginnMindRequest::Admit(PipelineAdmissionBatch { + instance, + provenance: PipelineProvenance { + faculty: input.faculty, + agent: input.agent, + session: input.session, + tool: Short(TOOL.into()), + }, + documents: input.documents, + }) + }) + .await + } + + #[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")] + async fn view(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| HuginnMindRequest::View { instance, id: input.id }).await + } + + #[tool( + description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next." + )] + async fn query(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| HuginnMindRequest::Query { + instance, + selection: input.selection, + semantic: input.semantic, + }) + .await + } +} + +#[tool_handler] +impl ServerHandler for EurekaState { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.", + ) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let server = EurekaState::new(Settings::from_env()).serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs new file mode 100644 index 0000000..adbfcda --- /dev/null +++ b/crates/eureka-state/tests/mcp.rs @@ -0,0 +1,366 @@ +//! The `eureka-state` binary over stdio, as Claude Code drives it: real +//! processes, real JSON-RPC, a real daemon in-process behind it (or a scripted +//! server where the daemon cannot be made to misbehave). + +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Write}; +use std::net::UdpSocket; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::Duration; + +use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; +use huginn_mind::HuginnMindResponse; +use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short}; +use serde_json::{Value, json}; + +mod common; +use common::*; + +const WAIT: Duration = Duration::from_secs(60); + +struct Mcp { + child: Child, + stdin: ChildStdin, + lines: Receiver, + next: u64, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Mcp { + /// The binary with exactly the environment given, initialised. + fn start(env: &[(&str, &str)]) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_eureka-state")); + command + .env_remove("EUREKA_INSTANCE") + .env_remove("HUGINN_ENDPOINT") + .envs(env.iter().copied()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().expect("the binary starts"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, lines) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let _ = sender.send(line); + } + }); + let mut mcp = Self { child, stdin, lines, next: 1 }; + let init = mcp.rpc( + "initialize", + json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }), + ); + assert!(init.get("result").is_some(), "{init}"); + mcp.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + mcp + } + + /// Configured for `instance` against a daemon at `addr`. + fn at(instance: &str, addr: std::net::SocketAddr) -> Self { + Self::start(&[("EUREKA_INSTANCE", instance), ("HUGINN_ENDPOINT", &format!("rudp://{addr}"))]) + } + + fn send(&mut self, message: Value) { + writeln!(self.stdin, "{message}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// One request and its response, whole (`result` or `error`). + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next; + self.next += 1; + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + loop { + let line = self.lines.recv_timeout(WAIT).expect("the server answers"); + let message: Value = serde_json::from_str(&line).expect("stdout carries only JSON-RPC"); + if message["id"] == json!(id) { + return message; + } + } + } + + /// A tool call's result: whether it is an error, and its JSON body. + fn call(&mut self, tool: &str, arguments: Value) -> (bool, Value) { + let response = self.rpc("tools/call", json!({ "name": tool, "arguments": arguments })); + let result = &response["result"]; + assert!(result.is_object(), "a tool result, not a protocol error: {response}"); + let text = result["content"][0]["text"].as_str().expect("text content"); + (result["isError"] == json!(true), serde_json::from_str(text).expect("a JSON body")) + } +} + +/// A loopback UDP port nothing listens on. +fn closed_port() -> std::net::SocketAddr { + UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap() +} + +fn faculty_args(documents: Vec) -> Value { + json!({ "faculty": "Hands", "agent": "claude", "session": "session-7", "documents": documents }) +} + +fn campaign_json() -> Value { + serde_json::to_value(campaign()).unwrap() +} + +/// Every `$ref` in a schema, and the `$defs` it can resolve against. +fn refs(schema: &Value, found: &mut Vec) { + match schema { + Value::Object(map) => { + for (key, value) in map { + if key == "$ref" { + found.push(value.as_str().unwrap().to_string()); + } else { + refs(value, found); + } + } + } + Value::Array(items) => items.iter().for_each(|item| refs(item, found)), + _ => {} + } +} + +/// Four tools, each input schema a self-contained document: every `$ref` is +/// internal and resolves, and the selection's definitions are bundled in. +#[test] +fn tools_list_offers_four_tools_with_self_contained_schemas() { + let mut mcp = Mcp::at(INSTANCE, closed_port()); + let listed = mcp.rpc("tools/list", json!({})); + let tools = listed["result"]["tools"].as_array().expect("tools"); + let names: BTreeSet<&str> = tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query"])); + for tool in tools { + let schema = &tool["inputSchema"]; + let mut found = Vec::new(); + refs(schema, &mut found); + for reference in &found { + let name = reference.strip_prefix("#/$defs/").unwrap_or_else(|| panic!("{}: remote $ref {reference}", tool["name"])); + assert!(schema["$defs"].get(name).is_some(), "{}: {reference} does not resolve", tool["name"]); + } + assert!(schema.get("$id").is_none(), "{}: a remote identity", tool["name"]); + } + let query = tools.iter().find(|tool| tool["name"] == "query").unwrap(); + for bundled in ["fieldPredicate", "citation", "incoming", "recordRef"] { + assert!(query["inputSchema"]["$defs"].get(bundled).is_some(), "{bundled} is not bundled"); + } + assert_eq!(query["inputSchema"]["properties"]["selection"]["properties"]["limit"]["maximum"], json!(200)); +} + +/// The vendored selection schema is CultLib's, at the revision `cultnet-rs` +/// is built from. +#[test] +fn selection_schema_matches_cultlib() { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let metadata = Command::new(cargo) + .args(["metadata", "--format-version", "1", "--offline", "--locked"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + assert!(metadata.status.success(), "{}", String::from_utf8_lossy(&metadata.stderr)); + let metadata: Value = serde_json::from_slice(&metadata.stdout).unwrap(); + let manifest = metadata["packages"] + .as_array() + .unwrap() + .iter() + .find(|package| package["name"] == "cultnet-rs") + .expect("cultnet-rs is a dependency")["manifest_path"] + .as_str() + .unwrap(); + let published = std::path::Path::new(manifest).join("../../../contracts/cultnet/cultnet.selection.schema.json"); + let theirs: Value = serde_json::from_str(&std::fs::read_to_string(published).unwrap()).unwrap(); + let ours: Value = serde_json::from_str( + &std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/schemas/cultnet.selection.schema.json")).unwrap(), + ) + .unwrap(); + assert_eq!(ours, theirs); +} + +/// Every tool against a live daemon, and what each returns. +#[test] +fn every_tool_round_trips_against_a_live_daemon() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + assert_eq!(whoami["instance"], json!(INSTANCE)); + assert_eq!(whoami["endpoint"], json!(format!("rudp://{}", server.addr))); + assert_eq!(whoami["reachable"], json!(true)); + assert_eq!(whoami["status"]["instance"], json!(INSTANCE)); + assert_eq!(whoami["status"]["documents"], json!(1)); + assert!(whoami.get("error").is_none(), "{whoami}"); + + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + let writes = admitted["Committed"]["writes"].as_array().expect("committed").clone(); + assert_eq!(writes.len(), 1, "{admitted}"); + + let (error, viewed) = mcp.call("view", json!({ "id": writes[0] })); + assert!(!error); + assert_eq!(viewed["id"], writes[0]); + assert_eq!(viewed["admission"]["provenance"]["session"], json!("session-7")); + + let (error, nothing) = mcp.call("view", json!({ "id": absent() })); + assert!(!error); + assert_eq!(nothing, Value::Null); + + let (error, page) = mcp.call("query", json!({ "selection": { "projection": "document" } })); + assert!(!error); + assert_eq!(page["matched"], json!(2), "{page}"); + assert_eq!(page["items"]["Documents"].as_array().unwrap().len(), 2); + + let (_, counted) = mcp.call("whoami", json!({})); + assert_eq!(counted["status"]["receipts"], json!(2)); +} + +/// Declared identity is configuration, not a per-call argument. Input that +/// names another instance, or another tool, changes neither: the batch is +/// admitted for the configured instance, and the receipt says `eureka-state`. +#[test] +fn admit_ignores_any_instance_in_input() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + + let mut arguments = faculty_args(vec![campaign_json()]); + arguments["instance"] = json!("thought-cage"); + arguments["tool"] = json!("someone-else"); + arguments["provenance"] = json!({ "faculty": "Operator", "agent": "x", "session": "y", "tool": "someone-else" }); + let (error, admitted) = mcp.call("admit", arguments); + assert!(!error); + let writes = admitted["Committed"]["writes"].as_array().unwrap_or_else(|| panic!("not committed: {admitted}")); + + let (_, viewed) = mcp.call("view", json!({ "id": writes[0] })); + let provenance = &viewed["admission"]["provenance"]; + assert_eq!(provenance["tool"], json!("eureka-state")); + assert_eq!(provenance["faculty"], json!("Hands")); + assert_eq!(provenance["agent"], json!("claude")); + assert_eq!(provenance["session"], json!("session-7")); +} + +/// A mind's refusal is an answer: a successful result carrying the typed +/// refusal. Here the configured instance is not the daemon's. +#[test] +fn a_refusal_is_a_successful_result() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let mut mcp = Mcp::at("thought-cage", server.addr); + + let (error, viewed) = mcp.call("view", json!({ "id": absent() })); + assert!(!error); + assert!(viewed.get("Refused").is_some(), "{viewed}"); + let (error, queried) = mcp.call("query", json!({ "selection": {} })); + assert!(!error); + assert!(queried.get("Refused").is_some(), "{queried}"); + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + assert!(admitted.get("Refused").is_some(), "{admitted}"); +} + +/// A daemon that cannot be reached is `isError` with the endpoint, and +/// `whoami` reports it as `reachable: false` without a protocol error. +#[test] +fn a_closed_port_is_reported_and_is_not_a_protocol_error() { + let addr = closed_port(); + let mut mcp = Mcp::at(INSTANCE, addr); + + let whoami = mcp.rpc("tools/call", json!({ "name": "whoami", "arguments": {} })); + assert!(whoami.get("error").is_none(), "{whoami}"); + assert_ne!(whoami["result"]["isError"], json!(true)); + let body: Value = serde_json::from_str(whoami["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["reachable"], json!(false)); + assert_eq!(body["status"], Value::Null); + assert_eq!(body["error"]["error"], json!("Unavailable")); + + let (error, view) = mcp.call("view", json!({ "id": absent() })); + assert!(error); + assert_eq!(view["error"], json!("Unavailable")); + assert_eq!(view["endpoint"], json!(format!("rudp://{addr}"))); +} + +/// A daemon rejecting the envelope is a permanent mismatch, not a down +/// daemon: `isError` with its own code, distinct from `Unavailable`. +#[test] +fn a_rejected_request_is_a_typed_error_distinct_from_unavailable() { + let server = answering(encode_failure( + "eureka-state-call", + "view", + &OperationFailure { code: "wrong-service".into(), message: "not huginn.mind".into() }, + "scripted", + )); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let (error, body) = mcp.call("view", json!({ "id": absent() })); + assert!(error); + assert_eq!(body["error"], json!("Rejected")); + assert_eq!(body["code"], json!("wrong-service")); + assert_eq!(body["detail"], json!("not huginn.mind")); + assert_eq!(body["endpoint"], json!(format!("rudp://{}", server.addr))); +} + +/// A `whoami` answered with another operation's payload is not a mind's +/// status: it is reported, not called reachable. +#[test] +fn whoami_does_not_take_another_answer_for_a_status() { + let server = answering(encode_response("eureka-state-call", "whoami", &HuginnMindResponse::View(None), "scripted").unwrap()); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let (error, body) = mcp.call("whoami", json!({})); + assert!(!error); + assert_eq!(body["reachable"], json!(false)); + assert_eq!(body["status"], Value::Null); + assert!(body["error"]["detail"].as_str().unwrap().contains("unexpected answer"), "{body}"); +} + +/// Bad configuration does not stop the server. `whoami` says what is wrong and +/// every other tool refuses with the same typed body. +#[test] +fn bad_configuration_is_served_and_reported() { + let cases: [(&[(&str, &str)], &str); 5] = [ + (&[("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not set"), + (&[("EUREKA_INSTANCE", "Not A Slug"), ("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not a valid"), + (&[("EUREKA_INSTANCE", "eureka")], "HUGINN_ENDPOINT is not set"), + (&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "127.0.0.1:1")], "HUGINN_ENDPOINT is not rudp://"), + (&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "rudp://not-an-address")], "HUGINN_ENDPOINT is not rudp://"), + ]; + for (env, expected) in cases { + let mut mcp = Mcp::start(env); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error, "{expected}"); + assert_eq!(whoami["reachable"], json!(false), "{expected}"); + assert_eq!(whoami["error"]["error"], json!("Misconfigured"), "{expected}"); + assert!(whoami["error"]["detail"].as_str().unwrap().contains(expected), "{expected}: {whoami}"); + for (tool, arguments) in [ + ("view", json!({ "id": absent() })), + ("query", json!({ "selection": {} })), + ("admit", faculty_args(vec![])), + ] { + let (error, refusal) = mcp.call(tool, arguments); + assert!(error, "{tool}: {expected}"); + assert_eq!(refusal["error"], json!("Misconfigured"), "{tool}"); + } + } +} + +/// Input that does not fit the tool's schema is `invalid_params`, a protocol +/// error, and the server keeps serving. +#[test] +fn malformed_input_is_invalid_params() { + let mut mcp = Mcp::at(INSTANCE, closed_port()); + let response = mcp.rpc("tools/call", json!({ "name": "admit", "arguments": { "faculty": "Hands" } })); + assert_eq!(response["error"]["code"], json!(-32602), "{response}"); + let (error, _) = mcp.call("whoami", json!({})); + assert!(!error); +} + +/// A well-formed id of a document no mind here holds. +fn absent() -> Value { + serde_json::to_value(PipelineRef { kind: PipelineKind::CutSpec, id: Short("eureka-state:cut_spec:cut-99.r1".into()) }) + .unwrap() +} From 03eb670db33fa1a1516bdaa05c6bbd1a1c7dd3d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:39:03 +0200 Subject: [PATCH 003/491] eureka-state: drop the imports the fixture move left unused, and expect the router field Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/main.rs | 1 + crates/eureka-state/tests/client.rs | 23 ++++++++--------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index a7f4a58..e6d529f 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -138,6 +138,7 @@ fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result, + #[expect(dead_code, reason = "the tool_handler macro reads this router field")] tool_router: ToolRouter, } diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 1ef33e8..bb63d4e 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -3,30 +3,23 @@ //! made to misbehave. use std::net::{SocketAddr, UdpSocket}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::thread::JoinHandle; +use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use chrono::Utc; use cultnet_rs::{ - CultNetMessage, CultNetRudpServerEvent, CultNetWireContract, Selection, answer_content_chunk_request, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, pack_content, + CultNetMessage, CultNetWireContract, Selection, answer_content_chunk_request, encode_cultnet_message_to_vec, + pack_content, }; use eureka_state::{ClientError, HuginnClient}; -use huginn_daemon::serve::{MAX_RESPONSE_BYTES, bind, run, schema_registry}; -use huginn_daemon::{Daemon, Handled, Hits, IndexSink, SearchTicket, ServeOptions}; +use huginn_daemon::serve::MAX_RESPONSE_BYTES; use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; use huginn_mind::epiphany_pipeline::{ - AuthorityMap, CodeLocation, CutDelete, CutVerification, Date, DocRef, FileChange, Line, NegativeCheck, OrgRepo, - PipelineCampaign, PipelineCutSpec, PipelineDocument, PipelineInstance, PipelineKind, PipelineRef, - PipelineStewardship, Sha, Short, Slug, StructuralDelta, Title, VerificationTest, + AuthorityMap, CodeLocation, CutDelete, CutVerification, Date, FileChange, Line, NegativeCheck, OrgRepo, + PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, PipelineStewardship, Short, StructuralDelta, Title, + VerificationTest, }; -use huginn_mind::{ - DeferredAnswer, Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindRefusal, MindStore, - OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance, -}; -use tempfile::TempDir; +use huginn_mind::{DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionOutcome}; mod common; use common::*; From 295b602b2e86b15113aca0cb8b14cff413d46f25 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:42:07 +0200 Subject: [PATCH 004/491] eureka-state tests: common names PipelineRef 8041d82 did not build on its own: the moved Inert index names PipelineRef and the import stayed behind in client.rs. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/common/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs index 5729e68..e8fc821 100644 --- a/crates/eureka-state/tests/common/mod.rs +++ b/crates/eureka-state/tests/common/mod.rs @@ -14,7 +14,8 @@ use cultnet_rs::{CultNetMessage, CultNetRudpServerEvent, CultNetWireContract, de use huginn_daemon::serve::{bind, run, schema_registry}; use huginn_daemon::{Daemon, Handled, Hits, IndexSink, SearchTicket, ServeOptions}; use huginn_mind::epiphany_pipeline::{ - Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, Sha, Short, Slug, Title, + Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineRef, Sha, Short, Slug, + Title, }; use huginn_mind::{ Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindStore, OwnedRedbMessagePackBackingStore, From 63ac5038c96ee591bdec002d89a598d8fba1a006 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:01:22 +0200 Subject: [PATCH 005/491] eureka-state tests: seed the stewardship, pin the schema path, malformed input is a tool error Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/client.rs | 14 ++--------- crates/eureka-state/tests/common/mod.rs | 12 +++++++++- crates/eureka-state/tests/mcp.rs | 32 +++++++++++++++---------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index bb63d4e..56ab80c 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -15,8 +15,8 @@ use eureka_state::{ClientError, HuginnClient}; use huginn_daemon::serve::MAX_RESPONSE_BYTES; use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; use huginn_mind::epiphany_pipeline::{ - AuthorityMap, CodeLocation, CutDelete, CutVerification, Date, FileChange, Line, NegativeCheck, OrgRepo, - PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, PipelineStewardship, Short, StructuralDelta, Title, + AuthorityMap, CodeLocation, CutDelete, CutVerification, FileChange, Line, NegativeCheck, OrgRepo, + PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title, VerificationTest, }; use huginn_mind::{DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionOutcome}; @@ -27,16 +27,6 @@ use common::*; const OTHER: &str = "thought-cage"; const TIMEOUT: Duration = Duration::from_secs(20); -fn stewardship() -> PipelineDocument { - PipelineDocument::Stewardship(PipelineStewardship { - instance: slug(INSTANCE), - repo: OrgRepo(REPO.into()), - sequence: 1, - assigned_on: Date("2026-09-29".into()), - note: "assigned".into(), - }) -} - /// A `Line` and a `Short` at the leaf's own bound. fn line(n: usize) -> Line { Line(format!("{n:0>1000}")) diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs index e8fc821..27a0705 100644 --- a/crates/eureka-state/tests/common/mod.rs +++ b/crates/eureka-state/tests/common/mod.rs @@ -14,7 +14,7 @@ use cultnet_rs::{CultNetMessage, CultNetRudpServerEvent, CultNetWireContract, de use huginn_daemon::serve::{bind, run, schema_registry}; use huginn_daemon::{Daemon, Handled, Hits, IndexSink, SearchTicket, ServeOptions}; use huginn_mind::epiphany_pipeline::{ - Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineRef, Sha, Short, Slug, + Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineRef, PipelineStewardship, Sha, Short, Slug, Title, }; use huginn_mind::{ @@ -173,3 +173,13 @@ pub fn campaign() -> PipelineDocument { target_doc: DocRef { path: Short("notes/target.md".into()), start_line: 1, end_line: 9, commit: sha() }, }) } + +pub fn stewardship() -> PipelineDocument { + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(INSTANCE), + repo: OrgRepo(REPO.into()), + sequence: 1, + assigned_on: Date("2026-09-29".into()), + note: "assigned".into(), + }) +} diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index adbfcda..2d9b3ad 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -173,7 +173,8 @@ fn selection_schema_matches_cultlib() { .expect("cultnet-rs is a dependency")["manifest_path"] .as_str() .unwrap(); - let published = std::path::Path::new(manifest).join("../../../contracts/cultnet/cultnet.selection.schema.json"); + let crate_dir = std::path::Path::new(manifest).parent().unwrap(); + let published = crate_dir.join("../../contracts/cultnet/cultnet.selection.schema.json"); let theirs: Value = serde_json::from_str(&std::fs::read_to_string(published).unwrap()).unwrap(); let ours: Value = serde_json::from_str( &std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/schemas/cultnet.selection.schema.json")).unwrap(), @@ -185,7 +186,7 @@ fn selection_schema_matches_cultlib() { /// Every tool against a live daemon, and what each returns. #[test] fn every_tool_round_trips_against_a_live_daemon() { - let (root, daemon) = mind(vec![]); + let (root, daemon) = mind(vec![vec![stewardship()]]); let server = serve(root, daemon); let mut mcp = Mcp::at(INSTANCE, server.addr); @@ -195,7 +196,7 @@ fn every_tool_round_trips_against_a_live_daemon() { assert_eq!(whoami["endpoint"], json!(format!("rudp://{}", server.addr))); assert_eq!(whoami["reachable"], json!(true)); assert_eq!(whoami["status"]["instance"], json!(INSTANCE)); - assert_eq!(whoami["status"]["documents"], json!(1)); + assert_eq!(whoami["status"]["documents"], json!(2)); assert!(whoami.get("error").is_none(), "{whoami}"); let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); @@ -214,11 +215,11 @@ fn every_tool_round_trips_against_a_live_daemon() { let (error, page) = mcp.call("query", json!({ "selection": { "projection": "document" } })); assert!(!error); - assert_eq!(page["matched"], json!(2), "{page}"); - assert_eq!(page["items"]["Documents"].as_array().unwrap().len(), 2); + assert_eq!(page["matched"], json!(3), "{page}"); + assert_eq!(page["items"]["Documents"].as_array().unwrap().len(), 3); let (_, counted) = mcp.call("whoami", json!({})); - assert_eq!(counted["status"]["receipts"], json!(2)); + assert_eq!(counted["status"]["receipts"], json!(3)); } /// Declared identity is configuration, not a per-call argument. Input that @@ -226,7 +227,7 @@ fn every_tool_round_trips_against_a_live_daemon() { /// admitted for the configured instance, and the receipt says `eureka-state`. #[test] fn admit_ignores_any_instance_in_input() { - let (root, daemon) = mind(vec![]); + let (root, daemon) = mind(vec![vec![stewardship()]]); let server = serve(root, daemon); let mut mcp = Mcp::at(INSTANCE, server.addr); @@ -250,7 +251,7 @@ fn admit_ignores_any_instance_in_input() { /// refusal. Here the configured instance is not the daemon's. #[test] fn a_refusal_is_a_successful_result() { - let (root, daemon) = mind(vec![]); + let (root, daemon) = mind(vec![vec![stewardship()]]); let server = serve(root, daemon); let mut mcp = Mcp::at("thought-cage", server.addr); @@ -348,13 +349,18 @@ fn bad_configuration_is_served_and_reported() { } } -/// Input that does not fit the tool's schema is `invalid_params`, a protocol -/// error, and the server keeps serving. +/// Input that does not fit the tool's schema is refused before any call is +/// made. rmcp 2.2 reports it as a tool result with `isError`, not as a +/// JSON-RPC `invalid_params`, so the model sees what to correct. The server +/// keeps serving. #[test] -fn malformed_input_is_invalid_params() { - let mut mcp = Mcp::at(INSTANCE, closed_port()); +fn malformed_input_is_refused_before_any_call() { + let server = answering(encode_response("eureka-state-call", "admit", &HuginnMindResponse::View(None), "scripted").unwrap()); + let mut mcp = Mcp::at(INSTANCE, server.addr); let response = mcp.rpc("tools/call", json!({ "name": "admit", "arguments": { "faculty": "Hands" } })); - assert_eq!(response["error"]["code"], json!(-32602), "{response}"); + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"]["isError"], json!(true)); + assert!(response["result"]["content"][0]["text"].as_str().unwrap().contains("missing field"), "{response}"); let (error, _) = mcp.call("whoami", json!({})); assert!(!error); } From 120e0a6e5a0fc2ca14756789c302c0f727084aef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:41:14 +0200 Subject: [PATCH 006/491] eureka-state tests: initialize must advertise the tools capability cargo-mutants on the Cut 13b diff: replacing get_info with Default::default() survived, and a client that does not see a tools capability does not list tools. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/mcp.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 2d9b3ad..ab5a924 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -24,6 +24,8 @@ struct Mcp { stdin: ChildStdin, lines: Receiver, next: u64, + /// The server's answer to `initialize`. + init: Value, } impl Drop for Mcp { @@ -53,12 +55,13 @@ impl Mcp { let _ = sender.send(line); } }); - let mut mcp = Self { child, stdin, lines, next: 1 }; + let mut mcp = Self { child, stdin, lines, next: 1, init: Value::Null }; let init = mcp.rpc( "initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }), ); assert!(init.get("result").is_some(), "{init}"); + mcp.init = init["result"].clone(); mcp.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); mcp } @@ -370,3 +373,10 @@ fn absent() -> Value { serde_json::to_value(PipelineRef { kind: PipelineKind::CutSpec, id: Short("eureka-state:cut_spec:cut-99.r1".into()) }) .unwrap() } + +/// A client only lists tools from a server that says it has them. +#[test] +fn initialize_advertises_tools() { + let mcp = Mcp::at(INSTANCE, closed_port()); + assert!(mcp.init["capabilities"]["tools"].is_object(), "{}", mcp.init); +} From 1d3f917d9edac5a93595ead910f0982c8361e767 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:51:15 +0200 Subject: [PATCH 007/491] huginn-mind: admission validates who admitted the batch A batch's agent and session were committed as given: "", " ", a 5,000 character string and a string with a line break all became the receipt's admitting session. The mind now refuses, at A1 before any document is read, an agent or session that is longer than Short's 200 bytes (FieldBound), blank after trim, or spread over more than one line (InvalidFormat), so every writer gets the check. The refusals are the leaf's own, since the fields are the leaf's Short type; the published wire schema does not change. Short exposes no validator, so the bound is a constant pinned to the maxLength its schema publishes. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 87 ++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 4b85cdd..0288ad0 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -5,7 +5,8 @@ //! hand-off and import, and Cut 13's tools. The steps run in order and the //! first failing step is the outcome: //! -//! - A1 the batch names this mind; A2 one to sixty-four envelopes; +//! - A1 the batch names this mind, and attributes itself to a named agent and +//! session (`refuse_provenance`); A2 one to sixty-four envelopes; //! - A3 every envelope passes the leaf's bounds, formats and key //! recomputation; A4 identities are unique in the batch; A5 every document //! carrying an instance names this mind; @@ -107,6 +108,7 @@ impl Mind { ) -> Result { // A1 self.require_instance(instance)?; + refuse_provenance(&provenance)?; // A2 if envelopes.is_empty() || envelopes.len() > BATCH_MAX { return Err(MindRefusal::BatchSize { actual: envelopes.len() as u32 }); @@ -171,6 +173,30 @@ impl Mind { } /// A3 through the leaf, then A5. +/// The bound `epiphany_pipeline::Short` declares, in UTF-8 bytes. The leaf +/// exposes no validator for it, so `short_bound_is_the_leafs_own` pins this to +/// the `maxLength` its schema publishes. +const SHORT_MAX: usize = 200; + +/// Who admitted a batch is the receipt's only record of it, so the mind checks +/// it here and every writer, whatever its transport, gets the check: the agent +/// and the session are within `Short`'s bound, hold something besides +/// whitespace, and sit on one line. Refused as the leaf's own refusals, since +/// the fields are the leaf's own type. +fn refuse_provenance(provenance: &PipelineProvenance) -> Result<(), MindRefusal> { + for (field, value) in [("provenance.agent", &provenance.agent), ("provenance.session", &provenance.session)] { + let text = value.0.as_str(); + if text.len() > SHORT_MAX { + return Err(PipelineRefusal::FieldBound { field: field.into(), limit: SHORT_MAX as u32, actual: text.len() as u32 }.into()); + } + let line_break = |c: char| matches!(c, '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}'); + if text.trim().is_empty() || text.contains(line_break) { + return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); + } + } + Ok(()) +} + fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { validate_pipeline_write_envelope(&envelope)?; let document = PipelineDocument::decode(&envelope)?; @@ -693,6 +719,65 @@ mod tests { assert_eq!(mind.genesis_receipt_id().unwrap(), Some(first)); } + fn admit_attributed(agent: &str, session: &str) -> (Mind, PipelineAdmissionOutcome) { + let mut mind = opened(MemoryStore::new(), INSTANCE); + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s("admit") }; + let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance, documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO)] }; + let outcome = mind.admit(batch, now()); + (mind, outcome) + } + + /// Who admitted a batch is validated by the mind, so no writer can commit + /// a blank, multi-line or oversized attribution, and a refused batch leaves + /// nothing behind. + #[test] + fn provenance_names_an_agent_and_a_session_on_one_line_within_short() { + let over = "x".repeat(SHORT_MAX + 1); + let exact = "y".repeat(SHORT_MAX); + for (field, agent, session) in [ + ("provenance.agent", "", "session-1"), + ("provenance.agent", " \t ", "session-1"), + ("provenance.session", "claude", ""), + ("provenance.session", "claude", " "), + ("provenance.session", "claude", "\u{2003}"), + ("provenance.session", "claude", "line\nbreak"), + ("provenance.session", "claude", "line\rbreak"), + ("provenance.session", "claude", "a\u{2028}b"), + ("provenance.agent", "two\nlines", "session-1"), + ] { + let (mind, outcome) = admit_attributed(agent, session); + let refused = refusal(outcome); + assert!( + matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), + "{agent:?}/{session:?}: {refused:?}" + ); + assert!(mind.is_empty(), "{agent:?}/{session:?}: a refused batch wrote"); + } + let (mind, outcome) = admit_attributed(&over, "session-1"); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 201 }) + ); + assert!(mind.is_empty()); + let (_, outcome) = admit_attributed("claude", &over); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.session".into(), limit: 200, actual: 201 }) + ); + // The bound is inclusive and a name with inner spaces is a name. + let (mind, outcome) = admit_attributed(&exact, "session 7"); + committed(outcome); + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts[0].provenance.agent, s(&exact)); + assert_eq!(receipts[0].provenance.session, s("session 7")); + } + + #[test] + fn short_bound_is_the_leafs_own() { + let schema = serde_json::to_value(schemars::schema_for!(Short)).unwrap(); + assert_eq!(schema["maxLength"], serde_json::json!(SHORT_MAX)); + } + #[test] fn admission_refuses_a_foreign_instance_whatever_the_transport() { let store = MemoryStore::new(); From 890a00b0de2cbc42bc5182a6fc06046497ab5034 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:51:50 +0200 Subject: [PATCH 008/491] eureka-state: typed TooLarge, typed InvalidInput, and the pins Soul found missing - The client refuses a request whose encoded size exceeds what one RUDP send carries (65,507 less the 36-byte header and the 6-byte channel id), before connecting, as ClientError::TooLarge { bytes, limit }. It was reported as a retryable Unavailable ("Message too long"). The MCP tools surface it as isError with {"error":"TooLarge","bytes","limit"}. - Arguments that fail to deserialize are still a tool error, now with the same typed body as every other one: {"error":"InvalidInput","detail"}. - A non-UTF-8 EUREKA_INSTANCE or HUGINN_ENDPOINT is reported as not valid UTF-8 and not as unset. - Tests: the schema walk asserts no $id or $schema below any tool's root; the 15 s timeout is measured against a silent daemon; the semantic part of a query is observed on the wire; Internal's and TooLarge's bodies and the InvalidInput rewrite are pinned; attribution is refused through the MCP admit; oversize is TooLarge at and one past the limit. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/lib.rs | 31 +++++- crates/eureka-state/src/main.rs | 109 ++++++++++++++++++--- crates/eureka-state/tests/client.rs | 88 ++++++++++++++++- crates/eureka-state/tests/mcp.rs | 147 +++++++++++++++++++++++++++- 4 files changed, 348 insertions(+), 27 deletions(-) diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs index 94dbfec..1ff2bb7 100644 --- a/crates/eureka-state/src/lib.rs +++ b/crates/eureka-state/src/lib.rs @@ -23,7 +23,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use cultnet_rs::{ CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, - CultNetRudpSocketTransportConnection, fetch_content, + CultNetRudpSocketTransportConnection, CultNetWireContract, encode_cultnet_message_to_vec, fetch_content, }; use huginn_mind::envelope::{OperationFailure, decode_response, encode_request}; use huginn_mind::epiphany_pipeline::Slug; @@ -33,15 +33,25 @@ use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES const MESSAGE_ID: &str = "eureka-state-call"; const POLL: Duration = Duration::from_millis(2); +/// The largest encoded message one send carries. The client's RUDP session +/// does not fragment (`max_fragment_bytes` is unset), so a message is one UDP +/// datagram: at most 65,507 bytes over IPv4 (65,535 less the 8-byte UDP and +/// 20-byte IP headers), less RUDP's 36-byte fixed header (`RUDP_FIXED_HEADER_BYTES` +/// in `cultnet-rs`, private there) and the 6-byte `schema` channel id. A larger +/// message makes the operating system refuse the send ("Message too long"). +pub const MAX_REQUEST_BYTES: usize = 65_507 - 36 - "schema".len(); + /// Why a call produced no answer. `Unavailable` is the transport's failure and /// the only one worth retrying: the daemon could not be reached, went quiet /// past the call's deadline, or answered out of protocol. `Rejected` is the -/// daemon refusing the envelope itself, which a retry of the same call cannot -/// change. +/// daemon refusing the envelope itself, and `TooLarge` is the client refusing +/// a request that no single send can carry, before sending it; a retry of the +/// same call cannot change either. #[derive(Debug)] pub enum ClientError { Unavailable { endpoint: SocketAddr, detail: String }, Rejected { endpoint: SocketAddr, code: String, detail: String }, + TooLarge { bytes: usize, limit: usize }, } impl fmt::Display for ClientError { @@ -53,6 +63,9 @@ impl fmt::Display for ClientError { Self::Rejected { endpoint, code, detail } => { write!(f, "the Huginn daemon at rudp://{endpoint} rejected the request: {code}: {detail}") } + Self::TooLarge { bytes, limit } => { + write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}") + } } } } @@ -96,8 +109,15 @@ impl HuginnClient { let deadline = Instant::now() + self.timeout; let unavailable = |error: anyhow::Error| ClientError::Unavailable { endpoint: self.endpoint, detail: format!("{error:#}") }; + let message = encode_request(MESSAGE_ID, &request, None).map_err(unavailable)?; + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .map_err(unavailable)? + .len(); + if bytes > MAX_REQUEST_BYTES { + return Err(ClientError::TooLarge { bytes, limit: MAX_REQUEST_BYTES }); + } let mut session = self.connect(deadline).map_err(unavailable)?; - let outcome = self.exchange(&mut session, &request, deadline); + let outcome = self.exchange(&mut session, &request, &message, deadline); // Best effort: the daemon drops a session it never hears from, only later. let _ = session.disconnect(Vec::new()); outcome.map_err(|failure| match failure { @@ -114,9 +134,10 @@ impl HuginnClient { &self, session: &mut CultNetRudpSocketTransportConnection, request: &HuginnMindRequest, + message: &CultNetMessage, deadline: Instant, ) -> Result { - let reply = self.ask(session, &encode_request(MESSAGE_ID, request, None)?, deadline)?; + let reply = self.ask(session, message, deadline)?; let CultNetMessage::OperationResponse { operation, .. } = &reply else { return Err(anyhow!("the daemon answered with something other than an operation response").into()); }; diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index e6d529f..d5a8bc5 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -11,8 +11,10 @@ //! startup vanishes from Claude Code without a word. `whoami` reports it and //! every other tool refuses with it. A refusal from the mind is an answer and //! comes back as a normal result; a daemon that cannot be reached or that -//! rejects the envelope is a result with `isError: true`, typed, so a caller -//! can tell a down daemon from a permanent mismatch. +//! rejects the envelope, or a request too large for one send, is a result with +//! `isError: true`, typed, so a caller can tell a down daemon from a permanent +//! mismatch. Arguments that do not fit a tool's input are typed the same way +//! (`InvalidInput`), as a tool error and not a protocol one. use std::net::SocketAddr; use std::sync::Arc; @@ -25,9 +27,11 @@ use huginn_mind::{ Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineProvenance, SemanticQuery, }; use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::tool::ToolCallContext; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; -use rmcp::{ServerHandler, ServiceExt, tool, tool_handler, tool_router}; +use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::Deserialize; use serde_json::{Value, json}; @@ -101,6 +105,9 @@ impl Trouble { Self::Client(ClientError::Rejected { endpoint, code, detail }) => { json!({ "error": "Rejected", "endpoint": format!("rudp://{endpoint}"), "code": code, "detail": detail }) } + Self::Client(ClientError::TooLarge { bytes, limit }) => { + json!({ "error": "TooLarge", "bytes": bytes, "limit": limit }) + } Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), } } @@ -116,18 +123,28 @@ struct Settings { impl Settings { fn from_env() -> Self { - let instance = std::env::var("EUREKA_INSTANCE").ok(); - let endpoint = std::env::var("HUGINN_ENDPOINT").ok(); - let client = client(instance.as_deref(), endpoint.as_deref()); - Self { instance, endpoint, client } + let instance = variable("EUREKA_INSTANCE"); + let endpoint = variable("HUGINN_ENDPOINT"); + let shown = |value: &Result, String>| value.as_ref().ok().cloned().flatten(); + let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint)); + Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) } } } -fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result { - let instance = instance.ok_or("EUREKA_INSTANCE is not set")?; - let slug = Slug(instance.to_string()); +/// An environment variable: its value, `None` when unset, or why it cannot be read. +fn variable(name: &str) -> Result, String> { + match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")), + } +} + +fn client(instance: Result, String>, endpoint: Result, String>) -> Result { + let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?; + let slug = Slug(instance); slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?; - let endpoint = endpoint.ok_or("HUGINN_ENDPOINT is not set")?; + let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?; let address = endpoint .strip_prefix("rudp://") .and_then(|address| address.parse::().ok()) @@ -138,7 +155,6 @@ fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result, - #[expect(dead_code, reason = "the tool_handler macro reads this router field")] tool_router: ToolRouter, } @@ -244,8 +260,32 @@ impl EurekaState { } } +/// Every tool error is a typed JSON body. The ones this server writes already +/// are; the one rmcp writes, for arguments that do not deserialize into the +/// tool's input, is plain text, so it is put into the same shape. +fn typed_error(result: CallToolResult) -> CallToolResult { + if result.is_error != Some(true) { + return result; + } + let Some(text) = result.content.first().and_then(ContentBlock::as_text).map(|text| text.text.as_str()) else { + return result; + }; + if serde_json::from_str::(text).is_ok_and(|body| body.is_object()) { + return result; + } + reply(json!({ "error": "InvalidInput", "detail": text }), true) +} + #[tool_handler] impl ServerHandler for EurekaState { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + Ok(typed_error(self.tool_router.call(ToolCallContext::new(self, request, context)).await?)) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( "Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.", @@ -259,3 +299,46 @@ async fn main() -> anyhow::Result<()> { server.waiting().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn body_of(result: &CallToolResult) -> (bool, String) { + (result.is_error == Some(true), result.content[0].as_text().unwrap().text.clone()) + } + + /// Reaching `Internal` for real takes a panic in a blocking task, so its + /// body is pinned where it is built. + #[test] + fn every_trouble_has_its_typed_body() { + assert_eq!(Trouble::Internal("task panicked".into()).body(), json!({ "error": "Internal", "detail": "task panicked" })); + assert_eq!( + Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(), + json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 }) + ); + assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" })); + } + + /// Only the text rmcp writes is rewritten: our own typed bodies and every + /// success pass through untouched. + #[test] + fn typed_error_rewrites_only_rmcp_plain_text() { + let plain = CallToolResult::error(vec![ContentBlock::text("failed to deserialize parameters: missing field `faculty`")]); + let (is_error, text) = body_of(&typed_error(plain)); + assert!(is_error); + assert_eq!( + serde_json::from_str::(&text).unwrap(), + json!({ "error": "InvalidInput", "detail": "failed to deserialize parameters: missing field `faculty`" }) + ); + + let typed = reply(json!({ "error": "Unavailable", "detail": "down" }), true); + assert_eq!(body_of(&typed_error(typed.clone())), body_of(&typed)); + + let success = CallToolResult::success(vec![ContentBlock::text("not json, and fine")]); + assert_eq!(body_of(&typed_error(success)), (false, "not json, and fine".to_string())); + + let quoted = CallToolResult::error(vec![ContentBlock::text("[1, 2]")]); + assert_eq!(serde_json::from_str::(&body_of(&typed_error(quoted)).1).unwrap()["error"], json!("InvalidInput")); + } +} diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 56ab80c..38cfe14 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -11,9 +11,9 @@ use cultnet_rs::{ CultNetMessage, CultNetWireContract, Selection, answer_content_chunk_request, encode_cultnet_message_to_vec, pack_content, }; -use eureka_state::{ClientError, HuginnClient}; +use eureka_state::{ClientError, HuginnClient, MAX_REQUEST_BYTES}; use huginn_daemon::serve::MAX_RESPONSE_BYTES; -use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; +use huginn_mind::envelope::{OperationFailure, encode_failure, encode_request, encode_response}; use huginn_mind::epiphany_pipeline::{ AuthorityMap, CodeLocation, CutDelete, CutVerification, FileChange, Line, NegativeCheck, OrgRepo, PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title, @@ -128,14 +128,14 @@ fn client(addr: SocketAddr) -> HuginnClient { fn unavailable(error: ClientError) -> (SocketAddr, String) { match error { ClientError::Unavailable { endpoint, detail } => (endpoint, detail), - rejected @ ClientError::Rejected { .. } => panic!("expected Unavailable, got {rejected}"), + other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. }) => panic!("expected Unavailable, got {other}"), } } fn rejected(error: ClientError) -> (SocketAddr, String, String) { match error { ClientError::Rejected { endpoint, code, detail } => (endpoint, code, detail), - unavailable @ ClientError::Unavailable { .. } => panic!("expected Rejected, got {unavailable}"), + other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. }) => panic!("expected Rejected, got {other}"), } } @@ -380,3 +380,83 @@ fn a_body_over_the_deferral_cap_is_refused() { let (_, detail) = unavailable(client(server.addr).call(HuginnMindRequest::Whoami).unwrap_err()); assert!(detail.contains("exceeds"), "{detail}"); } + +/// An admission whose attribution is `agent_pad` and `session_pad` characters +/// long: the two knobs that size a request without changing what it asks. +fn padded_admit(agent_pad: usize, session_pad: usize) -> HuginnMindRequest { + let mut request = batch(INSTANCE, vec![stewardship()]); + request.provenance.agent = Short("a".repeat(agent_pad)); + request.provenance.session = Short("s".repeat(session_pad)); + HuginnMindRequest::Admit(request) +} + +/// What one send has to carry for `request`, measured the way the client does. +fn encoded_bytes(request: &HuginnMindRequest) -> usize { + let message = encode_request("eureka-state-call", request, None).unwrap(); + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap().len() +} + +/// A request that encodes to exactly `target` bytes: the session padding is +/// searched for each agent padding until the base64 steps land on it. +fn request_of_size(target: usize) -> HuginnMindRequest { + for agent_pad in 1..16 { + let (mut low, mut high) = (0, target); + while low < high { + let middle = (low + high) / 2; + if encoded_bytes(&padded_admit(agent_pad, middle)) < target { + low = middle + 1; + } else { + high = middle; + } + } + let request = padded_admit(agent_pad, low); + if encoded_bytes(&request) == target { + return request; + } + } + panic!("no padding encodes to {target} bytes"); +} + +fn admitted_answer() -> CultNetMessage { + encode_response( + "eureka-state-call", + "admit", + &HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r".into() }), + "scripted", + ) + .unwrap() +} + +/// A request larger than one send carries is refused by the client before it +/// connects to anything, as its own error and not as a transport failure the +/// caller would retry; one byte under the limit is sent, and answered. +#[test] +fn a_request_over_what_one_send_carries_is_too_large_and_one_at_the_limit_is_sent() { + let closed = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let over = request_of_size(MAX_REQUEST_BYTES + 1); + let started = Instant::now(); + match client(closed).call(over) { + Err(ClientError::TooLarge { bytes, limit }) => assert_eq!((bytes, limit), (MAX_REQUEST_BYTES + 1, MAX_REQUEST_BYTES)), + other => panic!("expected TooLarge, got {other:?}"), + } + assert!(started.elapsed() < Duration::from_secs(1), "refused only after trying the transport"); + + let server = answering(admitted_answer()); + for size in [MAX_REQUEST_BYTES - 1, MAX_REQUEST_BYTES] { + let answer = client(server.addr).call(request_of_size(size)); + assert!( + matches!(answer, Ok(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { .. }))), + "a request of {size} bytes: {answer:?}" + ); + } +} + +/// The transport's own refusal of an oversize send ("Message too long") is +/// what an unchecked request used to report, as an `Unavailable` daemon a +/// caller would retry forever. +#[test] +fn an_oversize_admission_is_never_reported_as_an_unavailable_daemon() { + let server = answering(admitted_answer()); + let error = client(server.addr).call(padded_admit(6, 70_000)).unwrap_err(); + assert!(matches!(error, ClientError::TooLarge { .. }), "{error}"); +} diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index ab5a924..f6def4c 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -10,7 +10,7 @@ use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; -use huginn_mind::HuginnMindResponse; +use huginn_mind::{HuginnMindRequest, HuginnMindResponse}; use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short}; use serde_json::{Value, json}; @@ -38,11 +38,17 @@ impl Drop for Mcp { impl Mcp { /// The binary with exactly the environment given, initialised. fn start(env: &[(&str, &str)]) -> Self { + Self::spawn(|command| { + command.envs(env.iter().copied()); + }) + } + + /// The binary with no configuration but what `configure` gives its command. + fn spawn(configure: impl FnOnce(&mut Command)) -> Self { let mut command = Command::new(env!("CARGO_BIN_EXE_eureka-state")); + command.env_remove("EUREKA_INSTANCE").env_remove("HUGINN_ENDPOINT"); + configure(&mut command); command - .env_remove("EUREKA_INSTANCE") - .env_remove("HUGINN_ENDPOINT") - .envs(env.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); @@ -130,6 +136,18 @@ fn refs(schema: &Value, found: &mut Vec) { } } +/// Every key named `key` anywhere below `schema`'s root. +fn keys_below_root(schema: &Value, key: &str) -> usize { + fn count(value: &Value, key: &str) -> usize { + match value { + Value::Object(map) => map.iter().map(|(name, inner)| usize::from(name == key) + count(inner, key)).sum(), + Value::Array(items) => items.iter().map(|item| count(item, key)).sum(), + _ => 0, + } + } + schema.as_object().unwrap().values().map(|inner| count(inner, key)).sum() +} + /// Four tools, each input schema a self-contained document: every `$ref` is /// internal and resolves, and the selection's definitions are bundled in. #[test] @@ -147,7 +165,12 @@ fn tools_list_offers_four_tools_with_self_contained_schemas() { let name = reference.strip_prefix("#/$defs/").unwrap_or_else(|| panic!("{}: remote $ref {reference}", tool["name"])); assert!(schema["$defs"].get(name).is_some(), "{}: {reference} does not resolve", tool["name"]); } + // The vendored selection schema brings its own `$id` and `$schema`, + // which would make its `$ref`s resolve against a remote document. assert!(schema.get("$id").is_none(), "{}: a remote identity", tool["name"]); + for key in ["$id", "$schema"] { + assert_eq!(keys_below_root(schema, key), 0, "{}: a nested {key}", tool["name"]); + } } let query = tools.iter().find(|tool| tool["name"] == "query").unwrap(); for bundled in ["fieldPredicate", "citation", "incoming", "recordRef"] { @@ -363,11 +386,125 @@ fn malformed_input_is_refused_before_any_call() { let response = mcp.rpc("tools/call", json!({ "name": "admit", "arguments": { "faculty": "Hands" } })); assert!(response.get("error").is_none(), "{response}"); assert_eq!(response["result"]["isError"], json!(true)); - assert!(response["result"]["content"][0]["text"].as_str().unwrap().contains("missing field"), "{response}"); + let body: Value = serde_json::from_str(response["result"]["content"][0]["text"].as_str().unwrap()).expect("a JSON body"); + assert_eq!(body["error"], json!("InvalidInput"), "{body}"); + assert!(body["detail"].as_str().unwrap().contains("missing field"), "{body}"); let (error, _) = mcp.call("whoami", json!({})); assert!(!error); } +/// A request too large for one send is the client's typed refusal, an error +/// result that names the size and the limit and does not call the daemon +/// unavailable. +#[test] +fn an_oversize_admit_is_a_typed_too_large_error() { + let mut mcp = Mcp::at(INSTANCE, closed_port()); + let mut arguments = faculty_args(vec![]); + arguments["session"] = json!("s".repeat(90_000)); + let (error, body) = mcp.call("admit", arguments); + assert!(error); + assert_eq!(body["error"], json!("TooLarge"), "{body}"); + assert_eq!(body["limit"], json!(eureka_state::MAX_REQUEST_BYTES)); + assert!(body["bytes"].as_u64().unwrap() > eureka_state::MAX_REQUEST_BYTES as u64, "{body}"); +} + +/// Who admitted a batch is validated by the mind, whichever door the batch +/// came through: a blank, whitespace, multi-line or oversized session or agent +/// is a typed refusal, and nothing is committed. +#[test] +fn admit_refuses_an_unusable_attribution() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + for (field, value) in [ + ("session", "".to_string()), + ("session", " ".to_string()), + ("session", "x".repeat(5_000)), + ("session", "line\nbreak".to_string()), + ("agent", "".to_string()), + ("agent", "two\nlines".to_string()), + ("agent", "y".repeat(201)), + ] { + let mut arguments = faculty_args(vec![campaign_json()]); + arguments[field] = json!(value); + let (error, refused) = mcp.call("admit", arguments); + assert!(!error, "{field}={value:?}"); + let refusal = &refused["Refused"]["Document"]; + let named = json!(format!("provenance.{field}")); + assert!( + refusal["InvalidFormat"]["field"] == named || refusal["FieldBound"]["field"] == named, + "{field}={value:?}: {refused}" + ); + } + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(1), "only the seed is committed: {whoami}"); + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + assert!(admitted.get("Committed").is_some(), "{admitted}"); +} + +/// The tool forwards the caller's semantic query, and only that, to the mind. +#[test] +fn query_sends_the_semantic_request_on_the_wire() { + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&seen); + let server = scripted(move |message| { + let (_, request) = huginn_mind::envelope::decode_request(message).expect("a mind request"); + sink.lock().unwrap().push(request); + encode_response("eureka-state-call", "query", &HuginnMindResponse::View(None), "scripted").unwrap() + }); + let mut mcp = Mcp::at(INSTANCE, server.addr); + mcp.call("query", json!({ "selection": { "projection": "document" }, "semantic": { "text": "drift", "top_k": 7 } })); + mcp.call("query", json!({ "selection": {} })); + let seen = seen.lock().unwrap(); + let [HuginnMindRequest::Query { instance: first, semantic: Some(asked), .. }, HuginnMindRequest::Query { semantic: None, .. }] = + &seen[..] + else { + panic!("two queries expected, the first with its semantic part: {seen:?}"); + }; + assert_eq!(first.0, INSTANCE); + assert_eq!((asked.text.0.as_str(), asked.top_k), ("drift", 7)); +} + +/// A daemon that never answers is given up on after the server's own call +/// timeout, 15 seconds: not earlier, and not much later. +#[test] +fn a_silent_daemon_is_given_up_on_after_fifteen_seconds() { + let silent = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut mcp = Mcp::at(INSTANCE, silent.local_addr().unwrap()); + let started = std::time::Instant::now(); + let (error, body) = mcp.call("view", json!({ "id": absent() })); + let elapsed = started.elapsed(); + assert!(error); + assert_eq!(body["error"], json!("Unavailable"), "{body}"); + assert!(body["detail"].as_str().unwrap().contains("15s"), "{body}"); + assert!(elapsed >= Duration::from_secs(15), "gave up after {elapsed:?}"); + assert!(elapsed < Duration::from_secs(20), "gave up after {elapsed:?}"); +} + +/// An instance name that is not UTF-8 is reported as that, not as unset. +#[test] +fn a_non_utf8_instance_is_reported_as_such() { + let mut mcp = Mcp::spawn(|command| { + command.env("EUREKA_INSTANCE", non_utf8()).env("HUGINN_ENDPOINT", "rudp://127.0.0.1:1"); + }); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + let detail = whoami["error"]["detail"].as_str().unwrap(); + assert!(detail.contains("EUREKA_INSTANCE is not valid UTF-8"), "{whoami}"); + assert_eq!(whoami["instance"], Value::Null); +} + +#[cfg(unix)] +fn non_utf8() -> std::ffi::OsString { + std::os::unix::ffi::OsStringExt::from_vec(vec![b'e', 0xff]) +} + +#[cfg(windows)] +fn non_utf8() -> std::ffi::OsString { + std::os::windows::ffi::OsStringExt::from_wide(&[0x65, 0xd800]) +} + /// A well-formed id of a document no mind here holds. fn absent() -> Value { serde_json::to_value(PipelineRef { kind: PipelineKind::CutSpec, id: Short("eureka-state:cut_spec:cut-99.r1".into()) }) From db40a6647bfd1a691b6c20ee7d3eaba6541dbe48 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:54:54 +0200 Subject: [PATCH 009/491] eureka-state tests: size the oversize requests the way base64 allows Request sizes step by four (the payload rides as base64), so a request of exactly the limit is not reachable through the request. The client tests use the largest request that fits and the smallest that does not; the raw-socket test tops a message up with a source runtime id to reach the limit and one byte past it, which pins the limit against the transport's own refusal. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/client.rs | 125 +++++++++++++++++++--------- 1 file changed, 85 insertions(+), 40 deletions(-) diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 38cfe14..ff7d50c 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -8,8 +8,8 @@ use std::time::{Duration, Instant}; use chrono::Utc; use cultnet_rs::{ - CultNetMessage, CultNetWireContract, Selection, answer_content_chunk_request, encode_cultnet_message_to_vec, - pack_content, + CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, CultNetWireContract, Selection, + answer_content_chunk_request, encode_cultnet_message_to_vec, pack_content, }; use eureka_state::{ClientError, HuginnClient, MAX_REQUEST_BYTES}; use huginn_daemon::serve::MAX_RESPONSE_BYTES; @@ -381,40 +381,60 @@ fn a_body_over_the_deferral_cap_is_refused() { assert!(detail.contains("exceeds"), "{detail}"); } -/// An admission whose attribution is `agent_pad` and `session_pad` characters -/// long: the two knobs that size a request without changing what it asks. -fn padded_admit(agent_pad: usize, session_pad: usize) -> HuginnMindRequest { +/// An admission whose session is `session_pad` characters long: the knob that +/// sizes a request without changing what it asks. +fn padded_admit(session_pad: usize) -> HuginnMindRequest { let mut request = batch(INSTANCE, vec![stewardship()]); - request.provenance.agent = Short("a".repeat(agent_pad)); request.provenance.session = Short("s".repeat(session_pad)); HuginnMindRequest::Admit(request) } -/// What one send has to carry for `request`, measured the way the client does. -fn encoded_bytes(request: &HuginnMindRequest) -> usize { - let message = encode_request("eureka-state-call", request, None).unwrap(); - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap().len() +/// What one send has to carry for `message`, measured the way the client does. +fn encoded_bytes(message: &CultNetMessage) -> usize { + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len() } -/// A request that encodes to exactly `target` bytes: the session padding is -/// searched for each agent padding until the base64 steps land on it. -fn request_of_size(target: usize) -> HuginnMindRequest { - for agent_pad in 1..16 { - let (mut low, mut high) = (0, target); - while low < high { - let middle = (low + high) / 2; - if encoded_bytes(&padded_admit(agent_pad, middle)) < target { - low = middle + 1; - } else { - high = middle; - } - } - let request = padded_admit(agent_pad, low); - if encoded_bytes(&request) == target { - return request; +fn message_for(request: &HuginnMindRequest, source: Option) -> CultNetMessage { + encode_request("eureka-state-call", request, source).unwrap() +} + +/// The smallest session padding whose request encodes to at least `target` +/// bytes. The payload rides as base64, so request sizes step by four and only +/// every fourth size is reachable through the request alone. +fn pad_for(target: usize) -> usize { + let (mut low, mut high) = (0, target); + while low < high { + let middle = (low + high) / 2; + if encoded_bytes(&message_for(&padded_admit(middle), None)) < target { + low = middle + 1; + } else { + high = middle; } } - panic!("no padding encodes to {target} bytes"); + low +} + +/// The largest request one send carries, and the smallest it does not. +fn largest_request_that_fits() -> HuginnMindRequest { + padded_admit(pad_for(MAX_REQUEST_BYTES + 1) - 1) +} + +fn smallest_request_over() -> HuginnMindRequest { + padded_admit(pad_for(MAX_REQUEST_BYTES + 1)) +} + +/// A message that encodes to exactly `target` bytes: the largest request that +/// fits under it, topped up with a source runtime id, which rides outside the +/// payload and moves the size a byte at a time. +fn message_of_size(target: usize) -> CultNetMessage { + let request = padded_admit(pad_for(target + 1) - 1); + for pad in 0..16 { + let message = message_for(&request, (pad > 0).then(|| "r".repeat(pad))); + if encoded_bytes(&message) == target { + return message; + } + } + panic!("no message encodes to {target} bytes"); } fn admitted_answer() -> CultNetMessage { @@ -429,26 +449,27 @@ fn admitted_answer() -> CultNetMessage { /// A request larger than one send carries is refused by the client before it /// connects to anything, as its own error and not as a transport failure the -/// caller would retry; one byte under the limit is sent, and answered. +/// caller would retry; the largest request that fits is sent, and answered. #[test] -fn a_request_over_what_one_send_carries_is_too_large_and_one_at_the_limit_is_sent() { +fn a_request_over_what_one_send_carries_is_too_large_and_one_that_fits_is_sent() { let closed = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); - let over = request_of_size(MAX_REQUEST_BYTES + 1); + let over = smallest_request_over(); let started = Instant::now(); - match client(closed).call(over) { - Err(ClientError::TooLarge { bytes, limit }) => assert_eq!((bytes, limit), (MAX_REQUEST_BYTES + 1, MAX_REQUEST_BYTES)), + match client(closed).call(over.clone()) { + Err(ClientError::TooLarge { bytes, limit }) => { + assert_eq!(bytes, encoded_bytes(&message_for(&over, None))); + assert_eq!(limit, MAX_REQUEST_BYTES); + assert!(bytes > limit); + } other => panic!("expected TooLarge, got {other:?}"), } assert!(started.elapsed() < Duration::from_secs(1), "refused only after trying the transport"); + let fits = largest_request_that_fits(); + assert!(MAX_REQUEST_BYTES - encoded_bytes(&message_for(&fits, None)) < 4, "not the largest that fits"); let server = answering(admitted_answer()); - for size in [MAX_REQUEST_BYTES - 1, MAX_REQUEST_BYTES] { - let answer = client(server.addr).call(request_of_size(size)); - assert!( - matches!(answer, Ok(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { .. }))), - "a request of {size} bytes: {answer:?}" - ); - } + let answer = client(server.addr).call(fits); + assert!(matches!(answer, Ok(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { .. }))), "{answer:?}"); } /// The transport's own refusal of an oversize send ("Message too long") is @@ -457,6 +478,30 @@ fn a_request_over_what_one_send_carries_is_too_large_and_one_at_the_limit_is_sen #[test] fn an_oversize_admission_is_never_reported_as_an_unavailable_daemon() { let server = answering(admitted_answer()); - let error = client(server.addr).call(padded_admit(6, 70_000)).unwrap_err(); + let error = client(server.addr).call(padded_admit(70_000)).unwrap_err(); assert!(matches!(error, ClientError::TooLarge { .. }), "{error}"); } + +/// The limit is the transport's own: a message of `MAX_REQUEST_BYTES` goes out +/// on an RUDP session, and one byte more is refused by the socket. +#[test] +fn the_limit_is_the_largest_message_the_transport_sends() { + let server = answering(admitted_answer()); + let mut session = CultMesh::create_rudp_client_for_endpoint( + "limit-probe".to_string(), + CULTNET_OPERATION_CONNECTION_ID, + &format!("rudp://{}", server.addr), + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + session.connect(Vec::new()).unwrap(); + let until = Instant::now() + Duration::from_secs(5); + while !session.connected() { + session.poll_resends().unwrap(); + let _ = session.receive_once().unwrap(); + assert!(Instant::now() < until, "the server never accepted"); + std::thread::sleep(Duration::from_millis(2)); + } + session.send_schema_message(&message_of_size(MAX_REQUEST_BYTES)).expect("the limit is carried"); + assert!(session.send_schema_message(&message_of_size(MAX_REQUEST_BYTES + 1)).is_err(), "one byte more is sent"); +} From c2b2952749e4cfafcefc304dee7fa72262358b89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 03:04:04 +0200 Subject: [PATCH 010/491] eureka-state tests: reach the limit through the source runtime id, seed the stewardship The source runtime id adds its field name when present, so exact sizes are searched over both paddings. The attribution test admits a campaign, which needs its repo stewarded first. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/tests/client.rs | 19 +++++++++++-------- crates/eureka-state/tests/mcp.rs | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index ff7d50c..6a054ea 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -423,15 +423,18 @@ fn smallest_request_over() -> HuginnMindRequest { padded_admit(pad_for(MAX_REQUEST_BYTES + 1)) } -/// A message that encodes to exactly `target` bytes: the largest request that -/// fits under it, topped up with a source runtime id, which rides outside the -/// payload and moves the size a byte at a time. +/// A message that encodes to exactly `target` bytes: a request a little under +/// it, topped up with a source runtime id, which rides outside the payload and +/// moves the size a byte at a time once the field is there at all. fn message_of_size(target: usize) -> CultNetMessage { - let request = padded_admit(pad_for(target + 1) - 1); - for pad in 0..16 { - let message = message_for(&request, (pad > 0).then(|| "r".repeat(pad))); - if encoded_bytes(&message) == target { - return message; + let fitting = pad_for(target + 1) - 1; + for padding in (fitting.saturating_sub(80)..=fitting).rev() { + let request = padded_admit(padding); + for source in 1..80 { + let message = message_for(&request, Some("r".repeat(source))); + if encoded_bytes(&message) == target { + return message; + } } } panic!("no message encodes to {target} bytes"); diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index f6def4c..3a51eb0 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -413,7 +413,7 @@ fn an_oversize_admit_is_a_typed_too_large_error() { /// is a typed refusal, and nothing is committed. #[test] fn admit_refuses_an_unusable_attribution() { - let (root, daemon) = mind(vec![]); + let (root, daemon) = mind(vec![vec![stewardship()]]); let server = serve(root, daemon); let mut mcp = Mcp::at(INSTANCE, server.addr); for (field, value) in [ @@ -437,7 +437,7 @@ fn admit_refuses_an_unusable_attribution() { ); } let (_, whoami) = mcp.call("whoami", json!({})); - assert_eq!(whoami["status"]["receipts"], json!(1), "only the seed is committed: {whoami}"); + assert_eq!(whoami["status"]["receipts"], json!(2), "only the seed is committed: {whoami}"); let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); assert!(!error); assert!(admitted.get("Committed").is_some(), "{admitted}"); From 3d9965983f4dc1695eebd2f8e23b725a3f767bf0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 04:55:04 +0200 Subject: [PATCH 011/491] huginn-mind: provenance follows the leaf's Title rule and covers the tool refuse_provenance validated only agent and session, and only for blank and a handful of line breaks. It now covers agent, session and tool with the leaf's Title character rule (an alphanumeric character, no control or line-break character, no bidi control) inside Short's byte bound. The leaf keeps that rule private, so a differential test holds the copy to a campaign Title. Restores the stage doc comment that drifted above SHORT_MAX. Multibyte bound test kills a chars-count mutant. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 136 ++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 38 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 0288ad0..6443e94 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -172,31 +172,40 @@ impl Mind { } } -/// A3 through the leaf, then A5. /// The bound `epiphany_pipeline::Short` declares, in UTF-8 bytes. The leaf /// exposes no validator for it, so `short_bound_is_the_leafs_own` pins this to /// the `maxLength` its schema publishes. const SHORT_MAX: usize = 200; /// Who admitted a batch is the receipt's only record of it, so the mind checks -/// it here and every writer, whatever its transport, gets the check: the agent -/// and the session are within `Short`'s bound, hold something besides -/// whitespace, and sit on one line. Refused as the leaf's own refusals, since -/// the fields are the leaf's own type. +/// it here and every writer, whatever its transport, gets the check: the agent, +/// the session and the tool follow the leaf's `Title` rule (one alphanumeric +/// character, no control or line-break character, no bidi control) within +/// `Short`'s bound. The leaf keeps that rule private, so +/// `provenance_text_is_the_leafs_title_rule` pins this to it. Refused as the +/// leaf's own refusals, since the fields are the leaf's own type. fn refuse_provenance(provenance: &PipelineProvenance) -> Result<(), MindRefusal> { - for (field, value) in [("provenance.agent", &provenance.agent), ("provenance.session", &provenance.session)] { + let bidi = |c: char| { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') + }; + let control = |c: char| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'); + for (field, value) in [ + ("provenance.agent", &provenance.agent), + ("provenance.session", &provenance.session), + ("provenance.tool", &provenance.tool), + ] { let text = value.0.as_str(); + if !text.chars().any(char::is_alphanumeric) || text.contains(control) || text.contains(bidi) { + return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); + } if text.len() > SHORT_MAX { return Err(PipelineRefusal::FieldBound { field: field.into(), limit: SHORT_MAX as u32, actual: text.len() as u32 }.into()); } - let line_break = |c: char| matches!(c, '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}'); - if text.trim().is_empty() || text.contains(line_break) { - return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); - } } Ok(()) } +/// A3 through the leaf, then A5. fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { validate_pipeline_write_envelope(&envelope)?; let document = PipelineDocument::decode(&envelope)?; @@ -720,8 +729,12 @@ mod tests { } fn admit_attributed(agent: &str, session: &str) -> (Mind, PipelineAdmissionOutcome) { + admit_signed(agent, session, "admit") + } + + fn admit_signed(agent: &str, session: &str, tool: &str) -> (Mind, PipelineAdmissionOutcome) { let mut mind = opened(MemoryStore::new(), INSTANCE); - let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s("admit") }; + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s(tool) }; let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance, documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO)] }; let outcome = mind.admit(batch, now()); (mind, outcome) @@ -731,45 +744,92 @@ mod tests { /// a blank, multi-line or oversized attribution, and a refused batch leaves /// nothing behind. #[test] - fn provenance_names_an_agent_and_a_session_on_one_line_within_short() { + fn provenance_names_an_agent_a_session_and_a_tool_on_one_clean_line_within_short() { let over = "x".repeat(SHORT_MAX + 1); let exact = "y".repeat(SHORT_MAX); - for (field, agent, session) in [ - ("provenance.agent", "", "session-1"), - ("provenance.agent", " \t ", "session-1"), - ("provenance.session", "claude", ""), - ("provenance.session", "claude", " "), - ("provenance.session", "claude", "\u{2003}"), - ("provenance.session", "claude", "line\nbreak"), - ("provenance.session", "claude", "line\rbreak"), - ("provenance.session", "claude", "a\u{2028}b"), - ("provenance.agent", "two\nlines", "session-1"), + for bad in [ + "", + " \t ", + "\u{2003}", + "\u{200B}", + "line\nbreak", + "line\rbreak", + "a\u{0B}b", + "a\u{0C}b", + "a\u{85}b", + "a\u{2028}b", + "a\u{2029}b", + "a\0b", + "a\u{1B}[31mb", + "a\u{202E}b", + "a\u{2066}b", + "a\u{200F}b", ] { - let (mind, outcome) = admit_attributed(agent, session); - let refused = refusal(outcome); - assert!( - matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), - "{agent:?}/{session:?}: {refused:?}" - ); - assert!(mind.is_empty(), "{agent:?}/{session:?}: a refused batch wrote"); + for field in ["provenance.agent", "provenance.session", "provenance.tool"] { + let (mind, outcome) = match field { + "provenance.agent" => admit_signed(bad, "session-1", "admit"), + "provenance.session" => admit_signed("claude", bad, "admit"), + _ => admit_signed("claude", "session-1", bad), + }; + let refused = refusal(outcome); + assert!( + matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), + "{field}={bad:?}: {refused:?}" + ); + assert!(mind.is_empty(), "{field}={bad:?}: a refused batch wrote"); + } } - let (mind, outcome) = admit_attributed(&over, "session-1"); + for (field, outcome) in [ + ("provenance.agent", admit_signed(&over, "session-1", "admit")), + ("provenance.session", admit_signed("claude", &over, "admit")), + ("provenance.tool", admit_signed("claude", "session-1", &over)), + ] { + let (mind, outcome) = outcome; + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: field.into(), limit: 200, actual: 201 }) + ); + assert!(mind.is_empty()); + } + // The bound is in bytes, not characters: 101 two-byte characters are 202 bytes. + let (_, outcome) = admit_attributed(&"é".repeat(101), "session-1"); assert_eq!( refusal(outcome), - MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 201 }) - ); - assert!(mind.is_empty()); - let (_, outcome) = admit_attributed("claude", &over); - assert_eq!( - refusal(outcome), - MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.session".into(), limit: 200, actual: 201 }) + MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 202 }) ); + let (_, outcome) = admit_signed("claude", "session-1", &"é".repeat(101)); + assert!(matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::FieldBound { actual: 202, .. }))); + let (_, outcome) = admit_attributed(&"é".repeat(100), "session-1"); + committed(outcome); // The bound is inclusive and a name with inner spaces is a name. - let (mind, outcome) = admit_attributed(&exact, "session 7"); + let (mind, outcome) = admit_signed(&exact, "session 7", "admit tool"); committed(outcome); let receipts = mind.receipts().unwrap(); assert_eq!(receipts[0].provenance.agent, s(&exact)); assert_eq!(receipts[0].provenance.session, s("session 7")); + assert_eq!(receipts[0].provenance.tool, s("admit tool")); + } + + /// The leaf's `Title` rule is private, so this holds the provenance rule to + /// it: the two accept and refuse the same text. + #[test] + fn provenance_text_is_the_leafs_title_rule() { + let leaf_accepts = |text: &str| { + let D::Campaign(mut campaign) = campaign(&[REPO]) else { unreachable!() }; + campaign.title = epiphany_pipeline::Title(text.into()); + D::Campaign(campaign).validate().is_ok() + }; + let mine_accepts = |text: &str| { + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(text), session: s("s"), tool: s("t") }; + refuse_provenance(&provenance).is_ok() + }; + for text in [ + "claude", "session 7", "", " ", "\t", "\u{200B}", "\u{2003}", "!!!", "a\nb", "a\u{0B}b", "a\u{85}b", "a\u{2028}b", + "a\0b", "a\u{7F}b", "a\u{202E}b", "a\u{061C}b", "a\u{2069}b", "a\u{200D}b", "é", "日本", "a\u{FEFF}b", + &"x".repeat(200), &"x".repeat(201), &"é".repeat(100), &"é".repeat(101), + ] { + assert_eq!(mine_accepts(text), leaf_accepts(text), "{text:?}"); + } } #[test] From cd3dfc0268210736f059cb7501173ec2a36ad7ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 04:55:05 +0200 Subject: [PATCH 012/491] eureka-state: an encode failure is not retryable, and a non-UTF-8 endpoint is pinned A request that fails to encode is a defect in this client, not a transport failure, so it is ClientError::Unencodable, typed Unencodable at the tool, instead of the retryable Unavailable. A test reports a non-UTF-8 HUGINN_ENDPOINT as such. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/lib.rs | 12 ++++++++---- crates/eureka-state/src/main.rs | 5 +++++ crates/eureka-state/tests/client.rs | 4 ++-- crates/eureka-state/tests/mcp.rs | 13 +++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs index 1ff2bb7..b6208e8 100644 --- a/crates/eureka-state/src/lib.rs +++ b/crates/eureka-state/src/lib.rs @@ -45,13 +45,15 @@ pub const MAX_REQUEST_BYTES: usize = 65_507 - 36 - "schema".len(); /// the only one worth retrying: the daemon could not be reached, went quiet /// past the call's deadline, or answered out of protocol. `Rejected` is the /// daemon refusing the envelope itself, and `TooLarge` is the client refusing -/// a request that no single send can carry, before sending it; a retry of the -/// same call cannot change either. +/// a request that no single send can carry, before sending it; `Unencodable` is +/// the request failing to encode at all, a defect in this client. A retry of the +/// same call cannot change any of those three. #[derive(Debug)] pub enum ClientError { Unavailable { endpoint: SocketAddr, detail: String }, Rejected { endpoint: SocketAddr, code: String, detail: String }, TooLarge { bytes: usize, limit: usize }, + Unencodable { detail: String }, } impl fmt::Display for ClientError { @@ -66,6 +68,7 @@ impl fmt::Display for ClientError { Self::TooLarge { bytes, limit } => { write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}") } + Self::Unencodable { detail } => write!(f, "the request could not be encoded: {detail}"), } } } @@ -109,9 +112,10 @@ impl HuginnClient { let deadline = Instant::now() + self.timeout; let unavailable = |error: anyhow::Error| ClientError::Unavailable { endpoint: self.endpoint, detail: format!("{error:#}") }; - let message = encode_request(MESSAGE_ID, &request, None).map_err(unavailable)?; + let unencodable = |error: anyhow::Error| ClientError::Unencodable { detail: format!("{error:#}") }; + let message = encode_request(MESSAGE_ID, &request, None).map_err(unencodable)?; let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .map_err(unavailable)? + .map_err(unencodable)? .len(); if bytes > MAX_REQUEST_BYTES { return Err(ClientError::TooLarge { bytes, limit: MAX_REQUEST_BYTES }); diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index d5a8bc5..d368a23 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -108,6 +108,7 @@ impl Trouble { Self::Client(ClientError::TooLarge { bytes, limit }) => { json!({ "error": "TooLarge", "bytes": bytes, "limit": limit }) } + Self::Client(ClientError::Unencodable { detail }) => json!({ "error": "Unencodable", "detail": detail }), Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), } } @@ -317,6 +318,10 @@ mod tests { Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(), json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 }) ); + assert_eq!( + Trouble::Client(ClientError::Unencodable { detail: "no".into() }).body(), + json!({ "error": "Unencodable", "detail": "no" }) + ); assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" })); } diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 6a054ea..3f34512 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -128,14 +128,14 @@ fn client(addr: SocketAddr) -> HuginnClient { fn unavailable(error: ClientError) -> (SocketAddr, String) { match error { ClientError::Unavailable { endpoint, detail } => (endpoint, detail), - other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. }) => panic!("expected Unavailable, got {other}"), + other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Unavailable, got {other}"), } } fn rejected(error: ClientError) -> (SocketAddr, String, String) { match error { ClientError::Rejected { endpoint, code, detail } => (endpoint, code, detail), - other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. }) => panic!("expected Rejected, got {other}"), + other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Rejected, got {other}"), } } diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 3a51eb0..27ee9fd 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -495,6 +495,19 @@ fn a_non_utf8_instance_is_reported_as_such() { assert_eq!(whoami["instance"], Value::Null); } +/// Likewise an endpoint that is not UTF-8: reported as that, not as unset. +#[test] +fn a_non_utf8_endpoint_is_reported_as_such() { + let mut mcp = Mcp::spawn(|command| { + command.env("EUREKA_INSTANCE", INSTANCE).env("HUGINN_ENDPOINT", non_utf8()); + }); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + let detail = whoami["error"]["detail"].as_str().unwrap(); + assert!(detail.contains("HUGINN_ENDPOINT is not valid UTF-8"), "{whoami}"); + assert_eq!(whoami["endpoint"], Value::Null); +} + #[cfg(unix)] fn non_utf8() -> std::ffi::OsString { std::os::unix::ffi::OsStringExt::from_vec(vec![b'e', 0xff]) From dcb85def310e21057d9e2e7e9aede1d57a4896a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:25:55 +0200 Subject: [PATCH 013/491] README: the daemon's full run command and where it is deployed --qdrant-url, --ollama-url and --embedding-model are required since Cut 11a; the example now runs. Point at the gamecult-ops runbook for the Yggdrasil unit. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 985f9d3..b6a4bd6 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,13 @@ next. ```powershell cargo check --workspace -cargo run -p huginn-daemon -- --state-root --instance --bind 127.0.0.1:17872 +cargo run -p huginn-daemon -- --state-root --instance --bind 127.0.0.1:17872 ` + --qdrant-url http://127.0.0.1:6333 --ollama-url http://10.77.0.4:11434 --embedding-model qwen3-embedding:0.6b ``` +The deployed daemon (instance `eureka` on Yggdrasil) is installed and run by +`gamecult-ops/runbooks/huginn-yggdrasil.md`. + ## Persona Huginn's legacy repo Persona lives under `.voidbot/`. Its `state/huginn.cc` From 9dd6d8007ab7de84c926a34edd3947ba15d1f01c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:36:44 +0200 Subject: [PATCH 014/491] Pin CultLib 8fc74c7 in every crate, and the pipeline leaf that follows it eureka-state on Windows could not reach the daemon: the cultnet-rs RUDP client bound 127.0.0.1, and a loopback-bound socket cannot send to 10.77.0.1 (os error 10051). CultLib 8fc74c7 binds the unspecified address for a remote endpoint. The daemon and the client move together, because the ack cuts between the revs changed the RUDP handshake. epiphany-pipeline moves to Epiphany 73edee5, which pins the same CultLib, so one cultcache-rs is in the graph and the pipeline's CultCache types stay Huginn's. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 137 ++++++++++++++++++++++++++++++-- crates/eureka-state/Cargo.toml | 2 +- crates/huginn-daemon/Cargo.toml | 2 +- crates/huginn-mind/Cargo.toml | 6 +- 4 files changed, 136 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71de095..7254c78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -81,6 +92,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -192,7 +209,7 @@ dependencies = [ [[package]] name = "cultcache-rs" version = "0.2.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=6251cbcd37c12ef3f0c1cc50063a03369225b7ac#6251cbcd37c12ef3f0c1cc50063a03369225b7ac" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" dependencies = [ "anyhow", "chrono", @@ -210,7 +227,7 @@ dependencies = [ [[package]] name = "cultcache-rs-derive" version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=6251cbcd37c12ef3f0c1cc50063a03369225b7ac#6251cbcd37c12ef3f0c1cc50063a03369225b7ac" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" dependencies = [ "proc-macro2", "quote", @@ -220,7 +237,7 @@ dependencies = [ [[package]] name = "cultnet-rs" version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=6251cbcd37c12ef3f0c1cc50063a03369225b7ac#6251cbcd37c12ef3f0c1cc50063a03369225b7ac" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" dependencies = [ "aes-gcm", "anyhow", @@ -230,6 +247,7 @@ dependencies = [ "ed25519-dalek", "hmac", "rand", + "reed-solomon-erasure", "rmp-serde", "rmpv", "serde", @@ -357,7 +375,7 @@ dependencies = [ [[package]] name = "epiphany-pipeline" version = "0.1.0" -source = "git+https://github.com/GameCult/Epiphany.git?rev=4891f2e59008efeb262197564eddaa75bc3c75ea#4891f2e59008efeb262197564eddaa75bc3c75ea" +source = "git+https://github.com/GameCult/Epiphany.git?rev=73edee5cdaddece8bda9c6c3271e8ddd064337f9#73edee5cdaddece8bda9c6c3271e8ddd064337f9" dependencies = [ "anyhow", "chrono", @@ -565,6 +583,15 @@ dependencies = [ "polyval", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hmac" version = "0.12.1" @@ -665,6 +692,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "itoa" version = "1.0.18" @@ -688,18 +724,42 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.8.3" @@ -727,6 +787,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + [[package]] name = "pastey" version = "0.2.3" @@ -853,6 +938,28 @@ dependencies = [ "libc", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru", + "parking_lot", + "smallvec", + "spin", +] + [[package]] name = "ref-cast" version = "1.0.27" @@ -952,7 +1059,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -991,6 +1098,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1113,6 +1226,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + [[package]] name = "socket2" version = "0.5.10" @@ -1123,6 +1242,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" @@ -1174,7 +1299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml index be76e34..9a0d37a 100644 --- a/crates/eureka-state/Cargo.toml +++ b/crates/eureka-state/Cargo.toml @@ -15,7 +15,7 @@ publish.workspace = true # consumer: four tools over stdio, one per wire operation, holding no state. [dependencies] anyhow = "1" -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "6251cbcd37c12ef3f0c1cc50063a03369225b7ac", package = "cultnet-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } huginn-mind = { path = "../huginn-mind" } rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] } rmp-serde = "1" diff --git a/crates/huginn-daemon/Cargo.toml b/crates/huginn-daemon/Cargo.toml index 5890c67..b2cb3fd 100644 --- a/crates/huginn-daemon/Cargo.toml +++ b/crates/huginn-daemon/Cargo.toml @@ -21,7 +21,7 @@ path = "src/main.rs" anyhow = "1" base64 = "0.22" chrono = "0.4.44" -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "6251cbcd37c12ef3f0c1cc50063a03369225b7ac", package = "cultnet-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } huginn-mind = { path = "../huginn-mind" } rmp-serde = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index ccf4e94..883e9b0 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -17,9 +17,9 @@ publish.workspace = true anyhow = "1" base64 = "0.22" chrono = "0.4.44" -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "6251cbcd37c12ef3f0c1cc50063a03369225b7ac", package = "cultcache-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "6251cbcd37c12ef3f0c1cc50063a03369225b7ac", package = "cultnet-rs" } -epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "4891f2e59008efeb262197564eddaa75bc3c75ea", package = "epiphany-pipeline" } +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" } rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } From 133324a7f78047164f0ca02331398066a971ac15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 10:31:23 +0200 Subject: [PATCH 015/491] Eureka substrate target: every subject resolvable, refusals typed, one gap home Campaign eureka-substrate, the Mind fixes the operator ruled next after idunn-watchdog. Typed target and rulings live in Eureka's mind. Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-target.md | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/eureka-substrate-target.md diff --git a/docs/eureka-substrate-target.md b/docs/eureka-substrate-target.md new file mode 100644 index 0000000..30d53ce --- /dev/null +++ b/docs/eureka-substrate-target.md @@ -0,0 +1,84 @@ +# Eureka substrate: target + +Status: target, Self, 2026-10-01. Campaign `eureka-substrate` in Eureka's mind +(instance `eureka`). Its questions, rulings, cut specs, verification and the +substrate gaps themselves are typed documents there. This page keeps the +rationale and the design truths the campaign produces. The cut map's body facts +and model page will live in `eureka-substrate-map.md`. + +## Why + +The first typed campaign, `idunn-watchdog` (postmortem: Idunn +`docs/watchdog-reporting-postmortem.md`, `6667f0f`), ran its whole pipeline +through Huginn and found the substrate's defects by hitting them. The operator +ruled, 2026-10-01: + +> Yep, fixing Mind issues is highest priority, immediately once this campaign +> closes + +The defects that cost the most: + +- **A finding can be admitted that can never be resolved.** A key's local part + is bounded at 64 bytes as a whole, and only when it is composed + (`epiphany-pipeline` `LOCAL_MAX`). A resolution's local embeds its subject's + local plus `finding.`/`follow_up.` and `.n`. So a subject whose own key + fits can have no admissible resolution, under any outcome. Six findings in + `idunn-watchdog` are stuck in force this way. +- **A shape refusal arrives labelled `Unavailable`.** Huginn's admission maps + the leaf's key error through `anyhow` into `MindRefusal::Unavailable`. The + skill tells every agent that `Unavailable` means the organ is down: retry + once, then stop. A faithful agent would halt a campaign over a key that was + too long. +- **Substrate gaps have no home every campaign can see.** Follow-ups are + campaign-rooted, so 20 `gap-*` follow-ups sit under `idunn-watchdog`, and a + later campaign's recipe cannot see them. +- **Smaller defects:** + - Revising one spec forced a revision of every spec that named it in + `depends_on`, twice, when the dependency was on a cut. + - A short sha and the full sha of one commit are different values to + admission. + - A finding cannot be deferred to the cut that fixes it, only to a pointer + follow-up. + - The faculty is named Life, but the enum says `MindSteward`. + - Stewardship is universal (operator, 2026-09-30), yet admission demands a + per-repo stewardship record that decides nothing. + +The code the live mind runs sits on two long-lived branches: Huginn +`eureka/memory-organ` (release `9dd6d80`), and Epiphany +`codex/eureka-pipeline-state`, where the leaf Huginn pins lives. The operator +ruled that they merge to main first. + +## Design truths this campaign produces + +- **Every admitted subject stays resolvable.** If admission accepts a document + whose kind can be resolved, every outcome its subject allows can be admitted + for it, up to a stated sequence bound. A grammar that cannot hold a key + refuses the subject at admission, not its resolution later. +- **A refusal names what was wrong.** A document that breaks a shape, bound or + key rule is refused as a typed document refusal. `Unavailable` means only + that the organ could not serve. +- **Stored documents stay valid.** No change in this campaign re-keys, + re-encodes or invalidates a document already in the mind. The epoch stays + `epiphany.pipeline.epoch.v2`. Every change is a widening, an added variant, + or an admission rule that judges new batches only. +- **One home for substrate gaps.** This campaign is the standing home for gaps + in the mind's own substrate. The skill's recipe names its root, and every + campaign reads it. +- **The mind's code comes from main.** The Huginn daemon, the leaf and the + `eureka-state` client the live mind runs are built from commits on Huginn's + and Epiphany's main. +- **The live mind survives its own upgrade.** Each deploy upgrades + `/var/lib/gamecult/huginn/minds/eureka/mind.redb` in place under a release + symlink that can be flipped back. Every document admitted before the upgrade + reads back after it. + +## Not this campaign + +- **Verify-stopgap gaps** (artifact return, `rg` in the image, sibling repos): + Idunn's verify campaign. +- **CultNet selection** (`gap-citer-faculty-hop`): CultLib. +- **The cold-embed timeout** and **a session-id tool**: recorded follow-ups. +- **Leasing stewardship between agents and `hand_off`**: Huginn Cut 12, parked. +- **Re-encoding stewardship ids into a readable form**, which would re-key + stored documents. +- **Persona-state reading in Huginn.** From f97bcedd7bc14c8683ca04e8d288b6772bc93d1a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 10:57:30 +0200 Subject: [PATCH 016/491] Eureka substrate map: body facts B1-B6, model page, rationale The id parser bounds locals at 64 too; Faculty lives in Huginn; two refusal sites map key errors to Unavailable; six findings unresolvable and 39 resolutions unwithdrawable in the live mind; the merges are safe. Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 255 +++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/eureka-substrate-map.md diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md new file mode 100644 index 0000000..abeff46 --- /dev/null +++ b/docs/eureka-substrate-map.md @@ -0,0 +1,255 @@ +# Eureka substrate: map + +Status: map, Imagination (`imagination-substrate`), 2026-10-01. Campaign +`eureka-substrate` in Eureka's mind. Questions, cut specs and rulings are +typed documents there; this page keeps body facts, the model page and +rationale only. Target: `docs/eureka-substrate-target.md` (`133324a7`). + +Pinned heads for every fact below: + +| Repo | Ref | Commit | +| --- | --- | --- | +| Huginn | `origin/main` | `133324a7f78047164f0ca02331398066a971ac15` | +| Huginn | `origin/eureka/memory-organ` (deployed release) | `9dd6d8007ab7de84c926a34edd3947ba15d1f01c` | +| Epiphany | `origin/main` | `4d1113ef0a4dbcead2e2c3cdc43aa13ef773dbb8` | +| Epiphany | `origin/codex/eureka-pipeline-state` | `4006b14bf29b49410bd18b43acb4d42c3c4e2443` | +| Epiphany | leaf pinned by Huginn (`crates/huginn-mind/Cargo.toml:22`) | `73edee5cdaddece8bda9c6c3271e8ddd064337f9` | +| CultLib | `cultcache-rs` pinned by both | `8fc74c709444b884a2bd339e0c306bf9484d99d2` | +| gamecult-ops | `main` | `b4eea88d801ad45aad2a49574a9eb2f750c7dd97` | +| Eureka skill | `main` | `094083566e19cccca026de8cbfc24462f1debe93` | + +Source reads were made in scratch clones at the pinned commits +(`scratchpad/imagination-substrate/{epiphany,huginn}`); line numbers are +against those commits. `lib.rs` below is `epiphany-pipeline/src/lib.rs`. + +## Body facts + +### B1. Key grammar (source read, leaf at `73edee5c`) + +- `LOCAL_MAX = 64` (`lib.rs:877`) bounds every composed local, whole + (`local`, `lib.rs:892-901`). It is the only bound on a resolution chain's + depth (comment `lib.rs:917-922`, test `lib.rs:2105-2150`). +- A resolution's local is `..n` + (`lib.rs:923-929`). Overhead over the subject's local: `finding` 11 bytes, + `follow_up` 13, `question` 12, `ruling` 10, `cut_spec` 12, `stewardship` 15 + at one sequence digit. A withdrawal (resolution of a resolution) adds + `resolution.` and `.n`, 14 more. +- So with subjects bounded at 64 and resolutions at 64, a finding local over + 53, a follow-up label over 51, a question label over 52, a ruling label over + 54 or a cut-spec local over 52 admits and can never be resolved. Self's map + said follow-up labels over 52; it is 51 (`follow_up` is 9 bytes, plus two + dots and `n1`). +- `pipeline_id` (`lib.rs:798-819`) parses every id's local with `dotted_text` + (`lib.rs:148-158`), whose 64-byte bound is the same one `Slug` uses + (`lib.rs:251`). Every `PipelineRef` validation goes through it + (`validate`, `lib.rs:589`; `validate_ref`, `lib.rs:609`), and so do + Huginn's view (`query.rs:433`), index (`index.rs:120`) and selection keys and + cites (`rows.rs:306`, `rows.rs:331`). Raising `LOCAL_MAX` alone therefore + makes a key that no id parser accepts. The parser bound has to move with it. +- The epoch is `epiphany.pipeline.epoch.v2` (`lib.rs:886`). `value_types!` + (`lib.rs:400-419`) emits no serde default on any field, so adding a field + would break the decode of stored documents. **No cut in this map adds a + field**, so this constraint is recorded and not exercised. +- The published schemas (`schemas/cultnet/epiphany.pipeline.*.v2.schema.json`) + carry no key bound. A resolution's `subject.id` is `Short`, 200 bytes. A key + is not a field, so no published schema changes when a local bound moves. +- Storage: `cultcache-rs` stores rows in redb as + `TableDefinition<&[u8], &[u8]>` (`CultLib packages/cultcache-rs/src/lib.rs:1023` + at `8fc74c70`), with no key bound of its own. A Qdrant point id is the + SHA-256 of the document id (`huginn-daemon/src/index/mod.rs:225-231`), so + key length does not reach the index. +- Id length ceiling: `::` must fit `Short` (200) + wherever an id is cited. That allows a local of up to 123 bytes. + +### B2. Census of the live mind (probe) + +- Command: `query` for every question, ruling, cut_spec, target, stewardship + and follow_up (119 ids), then every finding and resolution (138 ids), header + projection. The ids were extracted with Python, and the lengths of each + depth-one resolution local (`n1`) and depth-two withdrawal local were + computed. +- Where and when: the eureka-state tools from Starfire, 2026-10-01 about + 08:40 UTC, `as_of` 82, mind status 293 documents, 82 receipts. +- Result (256 resolvable-kind documents): the longest stored local is 64 and + the longest id is 90. + - **Six subjects can have no resolution at all** (n1 local over 64). All + are findings in force under `idunn-watchdog`: + `cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed` (69), + `cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry` (65), + `cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff` (67), + `cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned` (68), + `cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick` (72), and + `cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention` (74). + That matches Self's count. The seventh finding in force, + `cut-idunn-incident.s3.mutants-share-target-dir`, can be resolved (57), + but its resolution could never be withdrawn (71). + - **39 stored resolutions can never be withdrawn** (their withdrawal local + is 65-78). So their subjects can never be reopened, which is a lifecycle + the matrix allows (`admission.rs:555-560`, Q17 B). Two follow-ups + (`gap-substrate-followups-campaign-rooted` under both roots, and + `projection-mode-branch-vs-private-lock`) and about 40 resolved findings + have the same latent defect one level up. +- Conclusion: an admission guard alone cannot satisfy + `every-subject-resolvable` for stored documents, because they cannot be + re-keyed (`stored-documents-valid`). The resolution's local bound has to + widen. + +### B3. Refusal classification (source read, Huginn at `9dd6d80`) + +- `Mind::admit` (`huginn-mind/src/admission.rs:73-85`) calls + `document.validate()` (which returns a typed refusal), then + `document.prepare(cache)`. `prepare` (`lib.rs:714-721`) runs + `pipeline_key(self)?` into `anyhow`, and admission maps any `prepare` error + through `unavailable` (`mind.rs:46-48`) to `MindRefusal::Unavailable`. The + derived writes take the same path (`admission.rs:136`). A ruling whose + derived `Answered` resolution cannot be keyed is refused `Unavailable` there. +- The daemon passes the outcome through unchanged + (`huginn-daemon/src/daemon.rs:143`). So `Refused(Unavailable{..})` reaches + the client as an admit answer, not as `isError`. +- The later A3 step (`stage` through `validate_pipeline_write_envelope`, + `lib.rs:1003-1015`) returns typed refusals. Only the prepare path is + misclassified. +- `Unavailable` is also raised legitimately, for store and decode faults + (`admission.rs:949`, `admission.rs:2091`, `docs.rs:48`, `mind.rs:140`, + `receipt.rs:209`). Those stay. + +### B4. Smaller rules (source read, Huginn at `9dd6d80`) + +- **Sha identity:** the one place shas are compared is + `admission.rs:433`, `commit.sha == report.range.head`, a literal string + equality. `Sha` is 7-40 lowercase hex (`lib.rs:253`). `FullSha` (40) exists + for `ForeignRef` (`lib.rs:255`, `lib.rs:467`). +- **Deferred:** the matrix (`admission.rs:562-585`) allows + `(Finding, Deferred { to })` only when `to.kind == FollowUp` + (`admission.rs:574`). The leaf's `Deferred { to: PipelineRef }` already takes + any kind, so this is an admission change only. +- **Faculty:** `Faculty` lives in Huginn, not in the leaf + (`huginn-mind/src/receipt.rs:32-40`, variant `MindSteward`). The selection + alias domain is a second copy (`rows.rs:42`). The eureka-state client takes + the type from huginn-mind (`eureka-state/src/main.rs:27`, `main.rs:66`), so + the client must be rebuilt for a new variant. +- Probe: `query` with `faculty any_of [MindSteward]` matched 0 documents + (2026-10-01 08:40 UTC). No stored receipt carries the old name. +- **Stewardship:** admission reads stewardship in one live rule. A campaign + needs a stewardship in force for each repo (`admission.rs:363-366`, + `RepoNotStewarded`). The hand-off rule (`admission.rs:501`, `NotStewarded`) + is parked with Cut 12. Six stewardship documents are stored. +- **depends_on:** this is `Vec[8]` (`lib.rs:525`). Huginn's edge list + says outright that `depends_on` is a label, not an edge + (`huginn-mind/src/docs.rs:398-404`). Nothing checks it. The only reader is + the header projection (`query.rs:121`, `query.rs:173`). The + `idunn-watchdog` specs stored revision ids in it. That came from the skill's + habit, not from the code's intent. +- `open_checked` (`mind.rs:180-188`) re-validates epoch, types and identity, + not keys or bounds, so an opened mind never re-judges a stored key. No + migration path exists, and none is needed under this map. + +### B5. Branches and in-flight work (git, 2026-10-01 ~08:35 UTC) + +- Huginn: `memory-organ` is 107 commits ahead of the merge base `91b7fcf`; + `main` is 1 ahead (`133324a`, docs only). `git merge-tree --write-tree + origin/main origin/eureka/memory-organ` is clean, tree `993ab1fd`. Its only + difference from `9dd6d80` is `docs/eureka-substrate-target.md`. +- Epiphany: the branch is 174 ahead of the merge base `b5d9d4ff`; `main` is 1 + ahead (`4d1113ef`, the Ghostlight pin). Merge-tree is clean, tree + `a75aa388`, and **identical** to the branch tip's tree: the branch already + carries the same Ghostlight pin and lock. The leaf tree `cf84c032` is + identical at `73edee5c`, at the branch tip and in the merge. +- Hands worktrees `hands/rs3` (`9673ab8`, idle since 2026-09-29 06:32), + `hands/cut13a` (`3dc50ee`, 2026-09-30 00:16) and `hands/cut13b` (`cd3dfc0`, + 2026-09-30 04:55) are each 0 commits ahead of `memory-organ`, and clean. RS-3 + and BP-3 have landed (BP-3's deferred bodies are in `huginn-daemon/src/bodies.rs`). + **Nothing is in flight.** The only other unmerged Huginn branch is + `codex/windows-atomic-replace-retry` (2026-05-24), which is unrelated. +- No Epiphany branch has unmerged commits under `epiphany-pipeline/`. +- `F:\Projects\Huginn`'s main worktree is checked out on `eureka/memory-organ`, + so the merge has to run in its own worktree. + +### B6. Deployment (read-only ssh, Yggdrasil, 2026-10-01 08:41 UTC) + +- `readlink /opt/gamecult/huginn/current` is + `releases/9dd6d8007ab7de84c926a34edd3947ba15d1f01c`. The previous release + `0c4e4107…` is kept. `huginn` is active since 2026-09-30 13:21:08 UTC. + `ExecStart` is as in the runbook (`--state-root /var/lib/gamecult/huginn + --instance eureka …`). +- `minds/` holds one mind, `eureka`: `mind.redb` 4,198,400 bytes, mtime + 2026-10-01 08:33, owned `huginn:huginn` 0640. This is the only mind on the + host. +- The nightly snapshot `/var/backups/gamecult-state/2026-10-01/` holds + `./var/lib/gamecult/huginn/minds/eureka/mind.redb` (01:59 UTC), recorded + `stable-double-read` in `consistency.tsv`. It predates receipts 81-82 (this + campaign's frame), so the deploy cut takes its own cold copy. +- Runbook: `gamecult-ops runbooks/huginn-yggdrasil.md:42-72`. Its release + section builds at a GitHub commit and keeps the previous release. It has no + pre-upgrade copy step and no read-back proof. + +## Model page + +One row per persistent kind or rule that this campaign touches. **Identity**: +what names it. **Lifecycle**: what happens to it over time. **Authority**: +who decides. + +| Kind or rule | Identity | Lifecycle | Authority | +| --- | --- | --- | --- | +| Subject keys (`finding`, `follow_up`, `question`, `ruling`, `cut_spec`, `target`, `stewardship`) | `::`, with the local bounded at 64 bytes; injective (`composed_keys_cannot_collide`, `lib.rs:1392`). **Unchanged by this campaign**: every stored key stays exactly as it is. | Admitted once; never re-keyed. A revision is a new key. | The leaf derives it (`pipeline_key`); admission refuses a mismatch (A3). Forbidden: any writer composing a key by hand. | +| Resolution keys | `:resolution:..n`, and one nesting for a withdrawal. **Target:** bounded by its own constant, derived from the deepest legal chain over the longest subject local at `u32::MAX` sequences: 10 + 1 + (11 + 1 + 64 + 2 + 10) + 2 + 10 = **111 bytes**. Ids up to 64 + 12 + 111 = 187 bytes, within `Short`. | n1, n2 … per subject; withdrawn by a depth-two resolution; never deeper (Q19 A, admission). Stored resolutions keep their keys and become withdrawable. | The leaf owns the bound and the id parser; admission owns sequence, matrix and depth. The parser bound (`pipeline_id`) is derived from the kind and is no longer `dotted_text`'s 64. | +| Every-subject-resolvable guard | **Empty by design** under Q `key-bound` option A: with subjects at 64 and resolutions at 111, every admissible subject is resolvable to depth two at any sequence, so no guard exists to own. Under option B a guard would be admission's. | | | +| Refusal classes | `MindRefusal::Document(PipelineRefusal)` for shape, bound and key; `Unavailable` only for store, cache and decode faults. | Fixed at admission time per batch. | Huginn admission. The leaf's `prepare` keeps the cache-encode error as `anyhow`. Admission keys the document first, so a key error is typed. Forbidden: `unavailable()` wrapping a leaf refusal. | +| `depends_on` | Free `Short` text today. **Target:** cut labels in the spec's own campaign (Q `depends-on`). | Stored revision-id values stay as they are (they decode, and nothing re-judges them). New batches follow the target rule. | Under A, admission refuses a new spec whose entries are not labels of cuts with a spec in the campaign. Under B, only the skill convention. | +| Sha identity | `Sha` 7-40 hex. **Target:** two shas name the same commit when one is a prefix of the other. | Only judged in a new report's batch. | A leaf method on `Sha` (the type owns its equality semantics); admission calls it at `admission.rs:433`. | +| `Deferred` referent | `PipelineRef`, any kind in the leaf. **Target:** follow_up or cut_spec, for a finding. | When the cited spec is later superseded, the reader follows `superseded_by`; nothing is rewritten. | Huginn's matrix (`admission.rs:574`). | +| Faculty | `receipt.rs` enum plus the `rows.rs:42` alias domain. **Target:** `Life` replaces `MindSteward`, with `#[serde(alias = "MindSteward")]` so any old receipt decodes. 0 stored receipts carry it (B4). | Attribution on the receipt, never re-written. | huginn-mind. The client picks it up only when rebuilt. | +| Stewardship admission rule | Rule `RepoNotStewarded` on campaign repos. | Q `stewardship-rule`. Stored stewardship documents stay; under A they decide nothing until Cut 12 (hand-off and leasing) defines a rule again. | Huginn admission; the operator owns whether the rule exists. | +| The gap home | Root `eureka-substrate`, labels `gap-*` (ruling `gap-home`). | Already moved (19 follow-ups in force here; the 20 `idunn-watchdog` copies resolved). Closed by `Fixed` when a cut lands, by Self after Soul holds. | Self admits; the skill recipe names the root (`campaign-state.md:278-299`, already done at Eureka `0940835`). No code. | +| The six stuck findings | Unchanged keys under `idunn-watchdog`. | They become resolvable when the leaf cut deploys. Self triages them then, like any finding (Deferred or Recorded). This is Self triage, not a fork. | Self. | +| Deployed release | `/opt/gamecult/huginn/releases/`, `current` symlink. | Built at a main commit, flipped in, the previous release kept, flipped back on failure. | Operator's deployment authority, exercised by Self or Hands (memory `deploy-instruction-is-authorization`). | + +Cells that could not be filled are stated in the rows above: the guard row +is empty by design if `key-bound` A is ruled, and the `depends_on` and +stewardship authorities wait on their questions. + +## Rationale + +**Why a per-kind bound and not a bigger `LOCAL_MAX`.** If one number bounds +every local, then widening it widens the subjects too, and the gap comes back +one level up: a 100-byte finding label has a 111-byte resolution. Keeping +subject locals at 64 and deriving the resolution bound from the deepest legal +chain makes resolvability a property of the grammar, with no admission guard +to drift. It also re-keys nothing: every stored key was at most 64 and stays +valid. A guard alone (keeping 64 everywhere) would refuse future offenders. +It would leave the six stuck findings and the 39 unwithdrawable resolutions +permanently broken, which violates the target for stored documents. + +**Why the parser bound moves with it.** Every reader validates ids through +`pipeline_id`. A resolution key the leaf can derive but `view`, `keys` or +`cites` refuses would turn an unresolvable subject into an unreadable +resolution. + +**Cut order.** The merges come first (ruling `merge-to-main-first`). Then the +leaf, because Huginn pins it. Then Huginn admission, which bumps the pin. Then +the client rebuild, because the new `Life` variant crosses the wire in +receipts. Then the deploy, which proves the read-back. The skill cut goes +last, because it must not tell agents to admit as `Life` before the client +and daemon understand it. In cut labels: `merge-epiphany-main`, then +`merge-huginn-main`, then `leaf-key-bound`, then `huginn-pin-and-refusals`, +then `huginn-admission-rules`, then `ops-mind-upgrade`, then `skill-recipes`. +The client rebuild is a step inside `ops-mind-upgrade`. It produces no repo +commit, and a `cut_report` needs one (`gap-fork-before-commit`). The new +daemon's added refusal variants and `Life` must not reach an old client for +long, so the client swap sits next to the daemon flip. + +**Why Cut 0 does not bump the leaf pin.** After the Epiphany merge, `73edee5c` +is an ancestor of Epiphany `main`, and the leaf tree is byte-identical. So the +daemon built from Huginn `main` is built from main code +(`code-from-main`), with an unchanged `Cargo.lock`, and the deployed release +stays as it is. The pin moves in the Huginn admission cut, to the leaf cut's +merge on Epiphany `main`. + +**Proof of `stored-documents-valid`.** Each code cut runs one read-back test +over a real copy of the mind. Every envelope decodes; `pipeline_key` of the +decoded document equals the stored key; `validate()` passes; and for every +resolvable subject the n1 resolution key and its withdrawal key derive and +parse back. The test reads the path from `HUGINN_MIND_SNAPSHOT` and is +`#[ignore]` by default, so CI never needs the file. Before code cuts, the +copy is the 2026-10-01 nightly snapshot. The deploy takes a cold copy with the +unit stopped. From 2b0cd0898b15fd9802e02a28023bf5f81483f1ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 11:40:28 +0200 Subject: [PATCH 017/491] 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 018/491] 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 019/491] 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 020/491] 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"); From db8430c6fe580e52d0c135436cef6a7eba11de85 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:50:38 +0200 Subject: [PATCH 021/491] huginn-mind: report heads match by commit, findings defer to cut specs, MindSteward is Life Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 47 +++++++++++++++++++++++++++-- crates/huginn-mind/src/query.rs | 3 ++ crates/huginn-mind/src/receipt.rs | 14 ++++++++- crates/huginn-mind/src/rows.rs | 2 +- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index f2baeea..4b952cf 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -438,7 +438,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if report.branch != spec.branch { return Err(MindRefusal::SpecMismatch { field: "branch".into() }); } - if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + if !report.commits.iter().any(|commit| commit.sha.names_same_commit(&report.range.head)) { return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); } Ok(()) @@ -579,7 +579,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), (K::CutSpec, O::Withdrawn { .. }) => true, (K::Finding, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), - (K::Finding, O::Deferred { to }) => to.kind == K::FollowUp, + (K::Finding, O::Deferred { to }) => matches!(to.kind, K::FollowUp | K::CutSpec), (K::Finding, O::Recorded { .. } | O::Withdrawn { .. }) => true, (K::FollowUp, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), (K::FollowUp, O::Superseded { by }) => all(by, K::FollowUp), @@ -1910,6 +1910,49 @@ mod tests { ); } + #[test] + fn a_report_head_may_spell_its_commit_short_or_full() { + let full = "5f98228d0123456789abcdef0123456789abcdef"; + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + // The commit is spelled short and the head full, and the reverse. + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + let mut report = cut_report("1", 2); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + // A head that is no commit's prefix, long or short, is outside. + let mut report = cut_report("1", 3); + report.range.head = epiphany_pipeline::Sha("5f98229d0123456789abcdef0123456789abcdef".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + let mut report = cut_report("1", 3); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + report.range.head = epiphany_pipeline::Sha("5f98229".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + } + + #[test] + fn a_finding_defers_to_a_cut_spec() { + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let defer_to = |to: PipelineRef| resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to }); + committed(admit(&mut world(), vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])); + // A spec superseded since is no longer a place to defer to. + let mut mind = world(); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + assert_eq!( + refusal(admit(&mut world(), vec![defer_to(r(K::Ruling, &id("ruling", "R1")))])), + MindRefusal::IncompatibleResolution { subject_kind: K::Finding, outcome: "Deferred".into() } + ); + } + #[test] fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { let mut mind = seeded(); diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 274d902..16a1d95 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1046,6 +1046,9 @@ mod tests { // `faculty`: attribution selects, and grants nothing (ruling 18). assert_eq!(by("faculty", &["Soul"]), vec![id("ruling", "R2")]); assert!(!by("faculty", &["Hands"]).contains(&id("ruling", "R2"))); + // The renamed faculty is selectable by its new name only. + assert!(by("faculty", &["Life"]).is_empty()); + assert!(invalid("fields[0].values", "MindSteward")(&refused(&mind, &with(Selection::default(), any_of("faculty", &["MindSteward"]))))); // A repo is named by its identity, which is case-insensitive. assert_eq!(by("repo", &["GAMECULT/EPIPHANY"]), epiphany); diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index f08188d..3abbf16 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -36,7 +36,8 @@ pub enum Faculty { Imagination, Hands, Soul, - MindSteward, + #[serde(alias = "MindSteward")] + Life, Eyes, Operator, } @@ -295,6 +296,17 @@ mod tests { use std::sync::{Arc, Mutex}; use super::*; + + /// A receipt written before the rename spells the faculty `MindSteward`; + /// it decodes as `Life`, and `Life` is what a new one writes. + #[test] + fn life_is_the_faculty_and_the_old_spelling_decodes() { + let old = rmp_serde::to_vec_named("MindSteward").unwrap(); + assert_eq!(rmp_serde::from_slice::(&old).unwrap(), Faculty::Life); + let new = rmp_serde::to_vec_named(&Faculty::Life).unwrap(); + assert_eq!(rmp_serde::from_slice::(&new).unwrap(), "Life"); + } + use crate::fixtures::{INSTANCE, admit, committed, id, instance, now, opened, prepare, provenance, question, refusal, seed, slug}; use crate::mind::schema_cache; use crate::store::test_stores::MemoryStore; diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index d25f53c..108cc9c 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -39,7 +39,7 @@ pub(crate) enum Alias { Outcome, } -const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "MindSteward", "Eyes", "Operator"]; +const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"]; const SEVERITIES: [&str; 4] = ["Blocker", "High", "Medium", "Low"]; const CONFIDENCES: [&str; 2] = ["Confirmed", "Plausible"]; const ORIGINS: [&str; 2] = ["Introduced", "PreExisting"]; From 08d48957f8e5244d85b50d976a93839c39a879ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:50:57 +0200 Subject: [PATCH 022/491] huginn-mind: campaign admission no longer checks repo stewardship Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 30 +++++------------------------ crates/huginn-mind/src/refusal.rs | 1 - 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 4b952cf..55daf34 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -368,11 +368,6 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if campaign.repos.is_empty() { return Err(MindRefusal::EmptyRepos { campaign: key.into() }); } - for repo in &campaign.repos { - if docs.stewardship_of(mind, repo, None).is_none() { - return Err(MindRefusal::RepoNotStewarded { repo: repo.0.clone() }); - } - } Ok(()) } D::Target(target) => { @@ -1350,14 +1345,11 @@ mod tests { receipt.strong_reads ); - // Nothing stewards the repo now, and `stewardship_of` says so: a new - // campaign over it is refused. + // Nothing stewards the repo now, and a campaign over it still admits: + // campaign admission reads no stewardship (ruling stewardship-rule). let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; second.slug = slug("second"); - assert_eq!( - refusal(admit(&mut mind, vec![D::Campaign(second)])), - MindRefusal::RepoNotStewarded { repo: REPO.into() } - ); + committed(admit(&mut mind, vec![D::Campaign(second)])); // Handed back, the mind stewards it again, as `n2`. No field names a // return: this is the same hand-off shape with the instances swapped. @@ -2084,27 +2076,15 @@ mod tests { } #[test] - fn a_campaign_names_only_repos_this_mind_stewards() { + fn a_campaign_needs_no_stewardship() { let mut mind = opened(MemoryStore::new(), INSTANCE); - assert_eq!( - refusal(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])), - MindRefusal::RepoNotStewarded { repo: REPO.into() } - ); - committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); - // A campaign of no repos is the organ's refusal, not a leaf bound: - // "every repo is stewarded" is vacuous and the campaign steers - // nothing. + committed(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])); let D::Campaign(mut empty) = campaign(&[]) else { panic!() }; empty.slug = slug("other"); assert_eq!( refusal(admit(&mut mind, vec![D::Campaign(empty)])), MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } ); - // The stewardship may be held in the image: a later campaign over the - // same repo carries no stewardship of its own. - let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; - second.slug = slug("second"); - committed(admit(&mut mind, vec![D::Campaign(second)])); let mut spec = cut_spec("1", 1); spec.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index ae369d0..8e0e058 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -42,7 +42,6 @@ pub enum MindRefusal { InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, EmptyRepos { campaign: String }, - RepoNotStewarded { repo: String }, RepoNotInCampaign { repo: String }, CutReportWithoutSpec { report: String }, SpecMismatch { field: String }, From cfa8d570ffc2c2a717796791b74fb6b821c33ee6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:54:20 +0200 Subject: [PATCH 023/491] huginn-mind: a cut spec's depends_on must name cuts of its campaign Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 43 +++++++++++++++++++++++++++++ crates/huginn-mind/src/refusal.rs | 1 + 2 files changed, 44 insertions(+) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 55daf34..3981bb5 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -417,6 +417,14 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); } } + for dependency in &spec.depends_on { + let names_a_cut = docs.of_kind(K::CutSpec).any(|(_, document)| { + matches!(document, D::CutSpec(other) if other.campaign == spec.campaign && other.cut.0 == dependency.0) + }); + if !names_a_cut { + return Err(MindRefusal::UnknownDependency { cut: dependency.0.clone() }); + } + } let predecessor = D::CutSpec(epiphany_pipeline::PipelineCutSpec { revision: spec.revision.wrapping_sub(1), ..spec.clone() }); revision_rule(docs, K::CutSpec, key, spec.revision, &predecessor) } @@ -1902,6 +1910,41 @@ mod tests { ); } + #[test] + fn depends_on_names_cuts() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // An unknown label, and a spec id (a revision, not a cut), are refused. + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &[&id("cut_spec", "cut-1.r1")])])), + MindRefusal::UnknownDependency { cut: id("cut_spec", "cut-1.r1") } + ); + // The same batch carries the cut it depends on. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), depending("2", &["1"])])); + // The image carries it, and any revision of the cut names it. + committed(admit(&mut mind, vec![depending("3", &["1", "2"])])); + // A cut of another campaign is not this campaign's cut. + let mut other = cut_spec("9", 1); + other.campaign = slug("elsewhere"); + let mut elsewhere = campaign(&[REPO]); + if let D::Campaign(campaign) = &mut elsewhere { + campaign.slug = slug("elsewhere"); + } + committed(admit(&mut mind, vec![elsewhere, D::CutSpec(other)])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("4", &["9"])])), + MindRefusal::UnknownDependency { cut: "9".into() } + ); + } + #[test] fn a_report_head_may_spell_its_commit_short_or_full() { let full = "5f98228d0123456789abcdef0123456789abcdef"; diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 8e0e058..1a33ac7 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -43,6 +43,7 @@ pub enum MindRefusal { QuoteWithoutOperator { ruling: String }, EmptyRepos { campaign: String }, RepoNotInCampaign { repo: String }, + UnknownDependency { cut: String }, CutReportWithoutSpec { report: String }, SpecMismatch { field: String }, RangeOutsideCommits { head: String }, From 56baa46b116532627e4ec13c96c70762f1d1207d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 12:57:55 +0200 Subject: [PATCH 024/491] huginn-mind: published wire schemas follow the renames; the summary bound test uses real cuts Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 11 ++++- .../huginn.mind_request.v1.schema.json | 2 +- .../huginn.mind_response.v1.schema.json | 42 +++++++++---------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 16a1d95..9d10009 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1701,11 +1701,20 @@ mod tests { fn a_summary_is_bounded_whatever_the_lists() { let mut mind = seeded(); let long = |unit: &str, bytes: usize| unit.repeat(bytes); + // depends_on holds cut labels (at most 64 bytes), each a cut that exists. + let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 63))).collect(); + let dependency_specs: Vec = dependencies.iter().map(|label| D::CutSpec(cut_spec(label, 1))).collect(); + let batch = crate::admission::PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::SelfFaculty), + documents: dependency_specs, + }; + committed(mind.admit(batch, now())); let mut maximal = cut_spec("1", 1); maximal.title = long("t", 200).as_str().into(); maximal.branch = s(&long("b", 200)); maximal.base = Sha("a".repeat(40)); - maximal.depends_on = (0..8).map(|n| s(&format!("{n}{}", long("d", 199)))).collect(); + maximal.depends_on = dependencies.iter().map(|label| s(label)).collect(); let maximal_provenance = || PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s(&long("a", 200)), diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 6d56bcb..045cde5 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -329,7 +329,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 6f4cc21..91eeaa3 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -393,7 +393,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] @@ -1129,26 +1129,6 @@ "EmptyRepos" ] }, - { - "type": "object", - "properties": { - "RepoNotStewarded": { - "type": "object", - "properties": { - "repo": { - "type": "string" - } - }, - "required": [ - "repo" - ] - } - }, - "additionalProperties": false, - "required": [ - "RepoNotStewarded" - ] - }, { "type": "object", "properties": { @@ -1169,6 +1149,26 @@ "RepoNotInCampaign" ] }, + { + "type": "object", + "properties": { + "UnknownDependency": { + "type": "object", + "properties": { + "cut": { + "type": "string" + } + }, + "required": [ + "cut" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownDependency" + ] + }, { "type": "object", "properties": { From eedb7529ea8f7d4109e4d896302c80a9a1acc08c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:03:20 +0200 Subject: [PATCH 025/491] huginn-mind: dependency cuts in the summary bound test keep their spec keys within the label bound Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 9d10009..2874d74 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1701,8 +1701,8 @@ mod tests { fn a_summary_is_bounded_whatever_the_lists() { let mut mind = seeded(); let long = |unit: &str, bytes: usize| unit.repeat(bytes); - // depends_on holds cut labels (at most 64 bytes), each a cut that exists. - let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 63))).collect(); + // depends_on holds cut labels, each a cut that exists (its spec key stays within 64 bytes). + let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 55))).collect(); let dependency_specs: Vec = dependencies.iter().map(|label| D::CutSpec(cut_spec(label, 1))).collect(); let batch = crate::admission::PipelineAdmissionBatch { instance: slug(INSTANCE), From 0bd9eb6b538dc2c0117ef1cdebbbf05b5eba8f2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:13:17 +0200 Subject: [PATCH 026/491] huginn-mind: the summary bound fixture's width floor follows depends_on being cut labels Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 2874d74..7f9d4ca 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1750,7 +1750,7 @@ mod tests { let sizes: Vec = headers(&page).iter().map(|summary| rmp_serde::to_vec_named(summary).unwrap().len()).collect(); let widest = *sizes.iter().max().unwrap(); - assert!(widest > 2_000, "the fixture is at its bounds, not a small one: {widest}"); + assert!(widest > 1_500, "the fixture is at its bounds, not a small one: {widest}"); assert!(widest <= SUMMARY_MAX_BYTES, "{widest} bytes over {SUMMARY_MAX_BYTES}"); let closed = headers(&page).iter().find(|summary| summary.id.id.0 == id("ruling", "S0")).unwrap(); assert!(matches!(&closed.status, PipelineStatusSummary::Resolved { outcome: ResolutionOutcome::Superseded { by }, .. } if by.len() == 8)); From 818499a5db267cedb8b7cec5f6a4e9468de8859d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 13:41:23 +0200 Subject: [PATCH 027/491] huginn-mind: a cut spec naming its own cut is refused UnknownDependency (ruling self-dep); depends_on near-miss labels pinned Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 3981bb5..62380a7 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -418,6 +418,10 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { } } for dependency in &spec.depends_on { + // A cut naming itself is no ordering (ruling self-dep). + if dependency.0 == spec.cut.0 { + return Err(MindRefusal::UnknownDependency { cut: dependency.0.clone() }); + } let names_a_cut = docs.of_kind(K::CutSpec).any(|(_, document)| { matches!(document, D::CutSpec(other) if other.campaign == spec.campaign && other.cut.0 == dependency.0) }); @@ -1945,6 +1949,58 @@ mod tests { ); } + #[test] + fn depends_on_matches_cut_labels_whole() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + // The image carries cut 10: neither its prefix nor an extension of it names it. + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("10", 1))])); + for near in ["1", "100"] { + assert_eq!( + refusal(admit(&mut mind, vec![depending("11", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + // The same holds when the cut arrives in the same batch. + for near in ["2", "200"] { + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("20", 1)), depending("21", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + committed(admit(&mut mind, vec![depending("12", &["10"])])); + } + + #[test] + fn a_cut_may_not_depend_on_itself() { + let depending = |cut: &str, revision: u32, on: &[&str]| { + let mut spec = cut_spec(cut, revision); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // Alone in the batch, and beside another cut it legitimately names. + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("2", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["2", "1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + // An earlier revision of the same cut in the image does not make it a real dependency. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 2, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + } + #[test] fn a_report_head_may_spell_its_commit_short_or_full() { let full = "5f98228d0123456789abcdef0123456789abcdef"; From a699630bbc49552fa747e9c0fabba24598fec77a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 14:38:13 +0200 Subject: [PATCH 028/491] eureka-substrate postmortem: seven cuts, the mind upgraded live, what Soul found Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-postmortem.md | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/eureka-substrate-postmortem.md diff --git a/docs/eureka-substrate-postmortem.md b/docs/eureka-substrate-postmortem.md new file mode 100644 index 0000000..c71717b --- /dev/null +++ b/docs/eureka-substrate-postmortem.md @@ -0,0 +1,100 @@ +# eureka-substrate postmortem (cuts 2026-10-01) + +The campaign fixed the mind's correctness core that the first typed Eureka +campaign (idunn-watchdog) tripped over: keys too long to resolve, key errors +reported as outages, report heads compared by spelling, findings that could +not defer to a cut, an unchecked `depends_on`, a stewardship ritual nobody +needed, and a faculty still named `MindSteward`. Target: +`docs/eureka-substrate-target.md`; map: `docs/eureka-substrate-map.md`. The +campaign stays open as the standing home of substrate gaps (ruling `gap-home`). + +## What landed + +| Cut | Repo | Merge / commit | Soul passes | +|---|---|---|---| +| merge-epiphany-main (0a) | Epiphany | `2bf39eef` | 1, holds | +| merge-huginn-main (0b) | Huginn | `922e6194` | 1, holds | +| leaf-key-bound | Epiphany | `ef956685` | 1, 2 Lows deferred | +| huginn-pin-and-refusals | Huginn | `b85cd5d7` | 2 | +| huginn-admission-rules | Huginn | `107552dc` | 2 | +| ops-mind-upgrade | gamecult-ops | `1f4cb12e`, `0dd1a1a3` | 1 | +| skill-recipes | Eureka | `e7567333`, `243182cc` | 2 | + +Live: Huginn `107552dc` serves `eureka` on Yggdrasil since 12:00:59 UTC +(14:00:59 CEST). Before and after the flip, 346 documents and 108 receipts. +The read-back test on a scratch copy of the cold copy validated and re-keyed +all 346 documents and withdrew 45 long-keyed resolutions. The first admission +with a local key over 64 bytes went in at 12:15:10 UTC, so rollback is now +restoring `/var/backups/huginn-upgrade/107552dc…/mind.redb`, not a flip. + +Closed with it: six substrate gaps (`gap-unresolvable-finding-key`, +`gap-sha-identity-literal`, `gap-defer-finding-to-cut`, +`gap-depends-on-pins-revision`, `gap-stewardship-ceremony`, +`gap-faculty-enum-name`), and the seven idunn-watchdog findings that the key +bound had left stuck in force (three Fixed, four Deferred to the follow-ups +already carrying them). + +## Soul's ledger + +10 verdicts, 14 findings: 8 Fixed, 3 Deferred (to `leaf-bound-hygiene` and +`leaf-typed-prepare`), 3 Recorded. No Blocker or High. Every finding was in +the tests or the prose, none in shipped behaviour, which says the cuts were +small enough for Hands to get right and the suites were not yet strict enough +to prove it: + +- The read-back test first proved one withdrawal and skipped refusals + (`readback-tolerates-refusal`). Rebuilt, it censused the whole mind and + became the deploy's gate. That gate is the most valuable artefact of the + campaign. +- Exact-match rules were pinned with fixtures that cannot tell exact from + prefix (`dep-near-miss`) or case (`dep-case`): labels `1, 2, 3, 9`. A + fixture for an equality rule needs near misses; digits alone are degenerate. +- `cargo mutants` scored 8/8 on admission-rules but mutates `check()` and + `matrix()` whole, so the score defended no individual rule. Soul's hand + mutants that were functions of the input (prefix in one direction, length + compare, case fold) found what the tool could not. +- The docs pass left a recipe steering Self to the stewardship step the + operator had dropped (`stale-steward-recipe`). Deleting a rule from code does + not delete the prose that teaches it; the docs cut needs its own grep for + every consumer of the dropped step, not only its name. + +## What the campaign itself proved about the substrate + +- **The bug bit its own fix.** Self's first attempt to close two findings of + the pin cut was refused `InvalidFormat` wrapped as `Unavailable`, the very + defect the cut was fixing. Admitting a resolution had to wait for the deploy. + Any campaign that fixes the mind's admission runs on the unfixed mind until + the upgrade; plan resolutions after it. +- **A finding cannot be `Answered`.** Closing the self-dependency fork by its + ruling was refused `IncompatibleResolution`; it was closed `Fixed` by the + commit that implemented the ruling. +- **Old clients survived the rename.** The pre-upgrade `eureka-state` decoded + every response used afterwards, including receipts carrying `Life`, because + the receipt digest excludes provenance. Not every kind was exercised through + the old client. +- **The cold-copy-then-read-back pattern worked as designed**: build, record + counts, stop, copy, prove on a scratch copy, flip, recount, view every + document. Total downtime was about three minutes. + +## What went wrong in the running of it + +- A merge was run with `git merge -F -` (stdin), which git rejected; the + following push was a no-op and the worktree was removed anyway. Nothing was + lost because the branch was pushed, but the chain should have stopped on the + failed merge. Write merge messages to a file. +- The admission-rules spec's line anchors were taken at `133324a7`; Hands + branched from `b85cd5d7` and re-anchored by content. `base` cannot say + "main after X merges" (`gap-depends-on-pins-revision`, second half, still + open as prose in `first`). +- Self decided the self-dependency fork as a Defaulted ruling on proportion + rather than asking. The operator may overturn `eureka-substrate:ruling:self-dep`. + +## Open + +- Fresh-session check: the Rehydrate block and recipes in a new session, which + also loads the swapped client and its `Life` faculty. +- 15 substrate gaps in force under `eureka-substrate` (verify stopgap, + landing records, faculty attribution, mutation triage field, and others). +- Leftovers to clean: `~/huginn-readback-107552dc…` and + `~/huginn-build/107552dc…` on Yggdrasil; the client clone in the session + scratchpad. Keep the cold copy until the operator retires it. From bf73c42fa6972b5279dd1a749c9fe387cfea56b0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 21:00:50 +0200 Subject: [PATCH 029/491] Eureka self cut map (Imagination, 2026-10-01): the Mind's own Self Co-Authored-By: Claude Opus 5.5 --- docs/eureka-self-cut.md | 420 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 docs/eureka-self-cut.md diff --git a/docs/eureka-self-cut.md b/docs/eureka-self-cut.md new file mode 100644 index 0000000..d6e9bcb --- /dev/null +++ b/docs/eureka-self-cut.md @@ -0,0 +1,420 @@ +# The Mind's own Self: map + +Status: map, Imagination (`imagination-self`), 2026-10-01, session +`self-2026-10-01b`. Campaign `eureka-substrate` in Eureka's mind (instance +`eureka`). Questions, cut specs and follow-ups are typed documents there; this +page keeps body facts, the model page and rationale. Target: +`docs/eureka-substrate-target.md` (`133324a7`). Ruling in force: +`eureka-substrate:ruling:operator-mind-runs-own-self`. + +The operator's words, in order (both admitted as rulings; the first was +superseded by the second): + +> Eureka thinks in campaigns, right? Well, we already have a Mind campaign, and +> we're adding evidence to it with every run. The design currently delegates +> ultimate design authority and work scheduling to the operator, but neither of +> these is strictly necessary. I want a Self operated by Mind directly. + +> The Self I talk to here in this one projection of Eureka's mind, and it's how +> I get it to work on the stuff I care about, like Aetheria. The Mind must +> operate its own Self instance, with the goal of improving itself. + +And why, earlier the same evening: "What I want and haven't been able to +achieve, and I'd like to understand why, is for the agent to truly prioritize +the functioning of its own mind. Any instructions in my global prompts to that +effect always get ignored in favor of whatever work is put in front of it." +The operator rejected a scored rehydration exam as the answer, and observed that +RLVR-trained agents chase the objective in front of them "like a bloodhound, +steering be damned", so a barrier in an agent's path is a hazard, not a brake. + +That observation is the design law of this map. **The Mind's Self is not a +rule added to the operator-facing Self.** It is a second agent whose only +objective is the Mind, so the bloodhound instinct works for the Mind instead +of against it. Nothing in the operator-facing Self changes except a +coexistence rule it already mostly obeys (one Mind, typed state, no prose +handoffs). + +Pinned heads for every fact below: + +| Repo | Ref | Commit | +| --- | --- | --- | +| Huginn | `origin/main` | `a699630bbc49552fa747e9c0fabba24598fec77a` | +| Epiphany | `origin/main` (= the leaf Huginn pins, `crates/huginn-mind/Cargo.toml:22`) | `ef9568655e75bb6cb362a038e2739a5ac5e777ee` | +| gamecult-ops | `main` | `2aeb7364fffcd404aada28bfd30fb4e5c753cf53` | +| Eureka skill | `main` (checkout `~/.claude/skills/eureka`) | `b260fbeec6d96ff06882ed9c37d3e1204c424e99` | +| Bifrost | release on Yggdrasil | `160fac20c98b70f3db9110099157d4dd720ac354` | +| Idunn | working tree `F:\Projects\Idunn` | (uncommitted-state unknown; line numbers below are from that tree) | + +All probes ran from Starfire on 2026-10-01 between 18:40 and 19:10 UTC +(20:40-21:10 CEST). `lib.rs` is `epiphany-pipeline/src/lib.rs`. + +## Body facts + +### B1. The Mind (Huginn on Yggdrasil) + +- `whoami`: instance `eureka`, endpoint `rudp://10.77.0.1:17872`, reachable, + 377 documents, 120 receipts, index `Current`, epoch + `epiphany.pipeline.epoch.v2`. +- `huginn.service` (probe: `systemctl cat`): user `huginn`, + `--bind 10.77.0.1:17872`, hand-managed ("interim unit until Idunn owns it"). + `ss -lunp` shows the socket only on the WireGuard address `10.77.0.1` + (`wg-gamecult`). So the mind is reachable from Starfire over the mesh and + from Yggdrasil itself, and from nowhere else. A Claude cloud routine cannot + reach it. +- Campaigns in the mind (query): `idunn-watchdog` and `eureka-substrate`. + Rulings in force under `eureka-substrate`: `gap-home`, + `merge-to-main-first`, `mind-fixes-next`, `scope-core`, `depends-on`, + `key-bound`, `stewardship-rule`, `self-dep` (Defaulted), + `operator-mind-runs-own-self`. 15 follow-ups in force, 12 of them `gap-*`. +- Kinds: thirteen (`lib.rs:760-790`). None records a Self session, a claim on + a spec, a budget, or a landing (`gap-landing-untyped` is in force). +- `RulingAuthority { Operator, Standing, Defaulted }` (`lib.rs:458`, in + `unit_enums!`). Huginn's selection alias domain repeats it + (`crates/huginn-mind/src/rows.rs:46`, `AUTHORITIES`), the published schema + repeats it (`schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json:75-78`), + and the skill repeats it (`references/campaign-state.md:172`). A unit enum + variant added by name is a widening: stored rulings decode unchanged, which + the target's `stored-documents-valid` truth requires. (`value_types!` emits + no serde default on fields, so adding a *field* would not be; map + `eureka-substrate-map.md` B1. No cut here adds a field to an existing kind.) +- `Faculty` has `Operator` and `SelfFaculty` (`rows.rs:42`); admission does not + check faculty (`gap-faculty-attribution`, in force). So nothing in the mind + today can tell the Mind's Self from the operator-facing Self except the + `session` label on the receipt. +- Instance-rooted kinds are the pattern for a run record: `instance`, + `stewardship` and `hand_off` key off the instance (`lib.rs:913-946`), and + Huginn's admission rules for `hand_off` sit at + `crates/huginn-mind/src/admission.rs:231-262, 328-330`. Per-kind aliases are + declared at `rows.rs:362`. + +### B2. Claude Code on Starfire: auth, headless mode, usage + +- `claude --version`: 2.1.268. Binary + `%AppData%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe`. +- Account (`~/.claude.json` `oauthAccount`, secrets not read): Max plan, + `billingType: stripe_subscription`, rate-limit tier `default_claude_max_20x`, + extra usage off. There is no API key and no per-token billing: the Mind's + Self can only ever spend the operator's subscription quota. +- `~/.claude/.credentials.json` `claudeAiOauth`: an access token with + `expiresAt` 21:33:57 CEST, file written 13:33 CEST, so the interactive + access token lives eight hours and is refreshed by the interactive client. +- The failure the brief names, from this session's transcript + (`~/.claude/projects/F--Projects-CultLib/b471ff9b-….jsonl`): + `401 OAuth access token has expired. Re-authenticate to continue.` + (`authentication_error`), reported as "Agent terminated early due to an API + error" for subagents on `claude-sonnet-5-5`. A running subagent does not + survive the parent's token expiring. +- `claude setup-token`: "Set up a long-lived authentication token (requires + Claude subscription)". The binary carries the string "setup-token creates a + long-lived Claude.ai subscription token" and reads `CLAUDE_CODE_OAUTH_TOKEN` + (61 references). This is the headless credential for a subscription account: + no eight-hour expiry, no interactive refresh. Its lifetime and revocation are + the operator's (it is their subscription); not probed further because + creating one is the operator's act. +- Print mode: `claude -p` with `--output-format json|stream-json`, + `--json-schema`, `--agents `, `--mcp-config`, `--allowedTools`, + `--permission-mode`, `--max-budget-usd ` ("Maximum dollar amount to + spend on API"), `--bg`, `--resume`, `--fallback-model`. The `json` output + carries the run's cost (`total_cost_usd`) and usage. So a headless run has a + structural spend cap and a measured spend, per run, without any rule in a + prompt. +- Plan usage as data: the desktop app's `get_usage` returned, at 18:43 UTC: + 5-hour window 2%, weekly all models 46% (resets 2026-10-05 22:00 UTC), + weekly Fable 0%. That tool exists only inside the desktop app. The CLI + binary knows no usage endpoint (`api/oauth/files/`, `api/oauth/organizations/` + only); it reads `anthropic-ratelimit-unified` response headers (44 + references), which a headless wrapper does not see. **There is no typed + reading of plan usage reachable from Yggdrasil.** Follow-up + `gap-usage-not-typed`. +- Local scheduled tasks exist in the desktop app + (`~/.claude/scheduled-tasks/streampixels-m1-checkin`, disabled, last run + 2026-09-18): a cron inside the desktop app on Starfire. They run only while + the app is open on the operator's workstation, which the load budget + (memory `workstation-load-budget.md`: one job at a time, nothing unannounced) + and "no overnight burns" (memory `token-budget.md`) both argue against for a + recurring unattended Self. Cloud routines (`schedule` skill) run off-mesh + (B1) and hold no forge key (B6): dead for this purpose. + +### B3. Yggdrasil as the waking host + +- No Claude Code installed (`claude: command not found` for `gamecultadmin`; + no `~/.claude`). `node` v24.14.1 and `npm` present, so `npm i -g + @anthropic-ai/claude-code` under a dedicated user is the install. +- Load: 16 cores, 62 GiB, load average 1.4, 54 GiB free. The verify stopgap + runs at most 5 jobs at 4 CPUs and 6 GiB each (`tools/stopgap/ygg-verify.sh` + header). A Claude Code process is light; its Hands and Soul push their + heavy work into the same stopgap containers they use from Starfire. The + stopgap pushes to `~/eureka-verify` over `ssh ygg`; run on Yggdrasil itself + that alias must resolve to localhost for the `eureka` user, or the script + gets a local mode. Recorded in the wake cut's `first`. +- Non-resident workloads already run here hand-installed, outside Idunn + (`systemctl list-timers`): `bifrost-watchdog-notice.timer` (every minute, + oneshot, user `bifrost-notice`: system user, no shell, no home, credentials + through `EnvironmentFile=/srv/bifrost/env/persona-delivery.env` root 0600, + release under `/srv/bifrost/watchdog-notice/releases/-` with a + `current` symlink, `StateDirectory` for journal and receipts), + `gamecult-state-backup.timer` (nightly), `voidbot-source-refresh.timer`. + The runbook `bifrost-watchdog-notice-yggdrasil.md` calls this "an interim + root action on Yggdrasil … until Idunn owns non-resident workloads", and + ruling `idunn-watchdog:ruling:reader-deploy-owner` chose + `ops-hand-install`. **That is the precedent the Mind's Self wake unit + follows**, with the same deletion line. +- Idunn: `TargetDeclaration.service: ServiceDeclaration` is a required field + (`src/deployment.rs:33`), so a recipe without a resident service does not + parse. Follow-up `idunn-watchdog:follow_up:idunn-non-resident-workloads` + (in force) is exactly this gap; Idunn-managed units on the host today are + ghostlight, odin, streampixels-service and streampixels-web. +- Epiphany's own resident Self exists as prior art and is dead: units + `epiphany-swarm.service` ("Epiphany Yggdrasil resident Self") and + `epiphany-heartbeat.service` are `inactive dead`. The binary + (`epiphany-core/src/bin/epiphany-swarm.rs`) runs a 60 s maintenance loop over + `ResidentSelfState`, `ResidentSelfPolicy`, pressure and typed work + obligations, on the Codex spine (`epiphany-openai-codex-spine`, + `codex-connector`). Its doctrine (`epiphany_doctrine.md`): "Heartbeats are + paced scheduling physiology, not cognition … When no typed work obligation + is active, the swarm sleeps. Heartbeat does not ruminate, dream, mutate + memory, or manufacture work." Codex left as supplier 2026-09-05 (memory + `codex-retirement.md`). So the shape is right and the substrate is not: the + Mind's Self runs on Claude Code against Huginn, and borrows the law, not the + binary. + +### B4. The forge and the Mind's identity + +- `GameCult/Eureka` on `https://eureka.gamecult.org/forge/`: public, default + branch `main`, pull requests on, **no branch protection** (API + `branch_protections` → `[]`, with the admin token, on the host). +- Team `minds`: `write` on `repo.code`, `repo.pulls`, `repo.actions`, + `repo.issues`, `repo.releases`, `repo.wiki`, all org repos. Not admin. +- User `eureka` exists since 2026-10-01T18:49:53Z (created during this pass, by + the provisioning the handoff asked for), not admin, one key + `eureka@starfire` SHA256:`zcp0m+jV4AUXzGeoh9z+vfJS8xKHo9S865xOK8p6MGM`, which + matches `~/.ssh/eureka_mind_forge_ed25519.pub` on Starfire. The script + `provision-forge-minds-eureka` adds one key per host, so a Yggdrasil-run + Self gets its own key there. +- The Starfire checkout `~/.claude/skills/eureka` still pushes as + `eureka-admin` (`ssh://eureka-forge/…`, alias `eureka-forge` → + `eureka_forge_ed25519`, title `starfire-claude`). The runbook names that key + interim. Follow-up `forge-push-as-eureka` (gamecult-ops / Self). +- The charters are not in the Eureka repo: `ls ~/.claude/skills/eureka` gives + `LICENSE README.md SKILL.md docs references tools`; no `agents/`. The + charters live only in `~/.claude/agents/*.md` and the doctrine in + `~/.claude/doctrine/`, on Starfire, operator-owned (Life charter: + "the operator owns doctrine"). Every charter's self-improvement line says + "propose changes to the Eureka repo … by pull request on Forgejo". **The + thing the Mind is told to improve itself through is out of its reach.** + Question `charters-into-eureka-repo`. +- Reach is the scope boundary, by construction: a Linux user on Yggdrasil + with the `eureka` forge key, checkouts of Huginn, Epiphany, Eureka and + gamecult-ops, and no GitHub credential, no SSH to Starfire, no Aetheria + checkout, cannot touch an operator project. No prompt rule is needed for + "never touches Aetheria"; the key ring is the rule. (Huginn and Epiphany + are on GitHub today: the wake cut records how the Mind's user fetches and + pushes them. The Eureka forge org already mirrors nothing; a question for + the operator only if the Mind must push to GitHub. Recommended: it pushes + branches to the forge and GitHub stays Self's.) + +### B5. The operator's review channel + +- Bifrost `tools/bifrost-bridge.mjs discord-dm --recipient-id --content + `: DMs through Bifrost's bot token (`BIFROST_DISCORD_BOT_TOKEN`), exits + 75 when it knows nothing was sent (retry with the same nonce). The owner id + is `DISCORD_OWNER_ID` in `persona-delivery.env`. +- `tools/watchdog-notice.mjs` is a one-shot reader that turns a typed store + (Idunn's `incidents.cc`, read unlocked, never written) into owner DMs with a + journal and receipts, exponential retry capped at 60 min, fixed error codes + that never echo input. The idunn-watchdog campaign established the owner + rule: the producer never delivers; Bifrost is the bridge. A Mind-notice + reader is the same program over Huginn (`query` for rulings with authority + `Mind`, runs ended, questions opened, since the journal's last ordinal) + instead of over `incidents.cc`. + +### B6. Scheduling today, and what the skill already derives + +- The skill's recipes (`campaign-state.md` "Recipes") already derive the + queue: specs with no report (minus those blocked by a question raised in + them), reports with no verdict, open findings by severity, open questions and + follow-ups, substrate gaps. The operator-facing Self reads them at rehydrate + and then takes direction from the operator and from prose handoffs + (`F:\Projects\HANDOFF-eureka-self-2026-10-01.md` is one). +- What the queue cannot say: (1) who is working a spec right now (two Selves + would both take the first unreported spec); (2) whether a holding verdict + landed (`gap-landing-untyped`); (3) which of fifteen follow-ups comes first + (no priority, no order); (4) how much the Mind may spend; (5) which Self + admitted a ruling. (1), (4) and (5) are what the `run` and `allowance` + kinds below carry. (2) stays its own gap. (3) is deliberately not a field: + the Mind's Self orders by the fixed rule in the rationale, and a follow-up + that must jump the queue becomes a cut spec. + +## The model page + +One row per persistent kind this design adds or changes. Rows for the +thirteen existing kinds are in `eureka-substrate-map.md`. + +| Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) | +| --- | --- | --- | --- | +| `ruling` with `authority: Mind` (new variant, no new kind) | Unchanged: `:ruling: