From e808c619fd31c49bdb5337e98a3ca72c2393553b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:06:41 +0100 Subject: [PATCH 1/8] eureka-state: one text door; the MCP admit decodes through it The strict decode that refuses unknown keys by path moves from the huginn binary to eureka_state::door; the CLI re-exports it and the MCP admit takes documents as a Value (schema still Vec) and decodes through it, answering InvalidInput before any call. A research note with extra keys is refused by path and nothing is stored. Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/bin/huginn/door.rs | 153 +-------------- crates/eureka-state/src/bin/huginn/trouble.rs | 10 + crates/eureka-state/src/door.rs | 182 ++++++++++++++++++ crates/eureka-state/src/lib.rs | 1 + crates/eureka-state/src/main.rs | 15 +- crates/eureka-state/tests/mcp.rs | 45 +++++ 6 files changed, 256 insertions(+), 150 deletions(-) create mode 100644 crates/eureka-state/src/door.rs diff --git a/crates/eureka-state/src/bin/huginn/door.rs b/crates/eureka-state/src/bin/huginn/door.rs index d460da1..949ecca 100644 --- a/crates/eureka-state/src/bin/huginn/door.rs +++ b/crates/eureka-state/src/bin/huginn/door.rs @@ -1,17 +1,11 @@ -//! The text door: agent-written JSON becomes typed values here, strictly, and -//! nowhere else. The store's decode stays lenient (serde ignores unknown -//! keys), so a misspelled key would be dropped and the batch committed -//! without it. The door refuses instead: every key path present in the input -//! with a value and absent from the typed value's re-serialisation is named in -//! the refusal. Paths only, never values. +//! The CLI's argument reading. The strict decode of agent-written JSON is +//! `eureka_state::door`, shared with the MCP server. use std::io::Read; -use cultnet_rs::Selection; -use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind}; -use serde::Serialize; -use serde::de::DeserializeOwned; -use serde_json::{Value, error::Category}; +use huginn_mind::eureka_pipeline::PipelineKind; + +pub use eureka_state::door::{documents, selection, selection_value}; use crate::trouble::Trouble; @@ -41,40 +35,6 @@ fn stdin() -> Result { Ok(text) } -/// A batch: a JSON array of `{kind, value}` documents, or one such object. -pub fn documents(text: &str) -> Result, Trouble> { - let value = parse(text)?; - let (items, typed): (&[Value], Vec) = match &value { - Value::Array(items) => (items.as_slice(), objects(items).and_then(|()| from_text(text, "documents"))?), - Value::Object(_) => (std::slice::from_ref(&value), vec![from_text(text, "documents[0]")?]), - _ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")), - }; - let kept = reserialise(&typed, "documents")?; - let mut lost = Vec::new(); - for (index, (given, held)) in items.iter().zip(&kept).enumerate() { - dropped(given, held, &format!("documents[{index}]"), &mut lost); - } - refuse_lost(&lost)?; - Ok(typed) -} - -pub fn selection(text: &str) -> Result { - let value = parse(text)?; - if !value.is_object() { - return Err(Trouble::invalid("selection must be a JSON object")); - } - let typed: Selection = from_text(text, "selection")?; - let kept = reserialise(std::slice::from_ref(&typed), "selection")?; - let mut lost = Vec::new(); - dropped(&value, &kept[0], "selection", &mut lost); - refuse_lost(&lost)?; - Ok(typed) -} - -pub fn selection_value(value: &Value) -> Result { - selection(&value.to_string()) -} - /// The kind a pipeline id names in its middle segment (`::`). pub fn kind_of(id: &str) -> Result { let mut segments = id.splitn(3, ':'); @@ -88,112 +48,10 @@ pub fn kind_of(id: &str) -> Result { .ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)")) } -fn parse(text: &str) -> Result { - serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {}", place(&error)))) -} - -/// Where and what kind of failure a decode hit, never what the input said: -/// serde's own message quotes the offending value whole. -fn place(error: &serde_json::Error) -> String { - let category = match error.classify() { - Category::Io => "unreadable input", - Category::Syntax => "syntax", - Category::Data => "a value of the wrong type, an unknown variant or a missing field", - Category::Eof => "unexpected end", - }; - format!("{category} at line {} column {}", error.line(), error.column()) -} - -/// Serde would also read a struct from an array, positionally, so only an -/// object is a document or a selection. -fn objects(items: &[Value]) -> Result<(), Trouble> { - match items.iter().position(|item| !item.is_object()) { - None => Ok(()), - Some(index) => Err(Trouble::invalid(format!("documents[{index}] must be a JSON object"))), - } -} - -/// The typed value of the text, decoded from the text itself so the failure -/// has a real line and column. -fn from_text(text: &str, at: &str) -> Result { - serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("{at} does not decode: {}", place(&error)))) -} - -/// What the typed values hold, as the JSON items to compare with the input. -fn reserialise(typed: &[T], at: &str) -> Result, Trouble> { - typed - .iter() - .map(|item| serde_json::to_value(item).map_err(|_| Trouble::Internal(format!("{at} does not re-serialise")))) - .collect() -} - -fn refuse_lost(paths: &[String]) -> Result<(), Trouble> { - if paths.is_empty() { - Ok(()) - } else { - Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", ")))) - } -} - -/// The paths of `input` that carry a value and are absent from `kept`. -fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec) { - match (input, kept) { - (Value::Object(given), Value::Object(held)) => { - for (key, value) in given { - let here = format!("{path}.{key}"); - match held.get(key) { - Some(held) => dropped(value, held, &here, out), - None if !empty(value) => out.push(here), - None => {} - } - } - } - (Value::Array(given), Value::Array(held)) => { - for (index, (value, held)) in given.iter().zip(held).enumerate() { - dropped(value, held, &format!("{path}[{index}]"), out); - } - } - _ => {} - } -} - -/// A value whose loss changes nothing: the spellings of "not given". -fn empty(value: &Value) -> bool { - match value { - Value::Null => true, - Value::String(text) => text.is_empty(), - Value::Array(items) => items.is_empty(), - Value::Object(fields) => fields.is_empty(), - Value::Bool(_) | Value::Number(_) => false, - } -} - #[cfg(test)] mod tests { - use serde_json::json; - use super::*; - fn paths(input: &Value, kept: &Value) -> Vec { - let mut out = Vec::new(); - dropped(input, kept, "d", &mut out); - out.sort(); - out - } - - #[test] - fn a_dropped_key_with_a_value_is_named_by_path() { - let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 }); - let kept = json!({ "a": { "b": [ { "c": 1 } ] } }); - assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]); - } - - #[test] - fn a_dropped_key_without_a_value_is_not_a_loss() { - let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false }); - assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]); - } - #[test] fn kinds_come_from_the_middle_segment() { assert_eq!(kind_of("eureka-substrate:cut_spec:cut-x.r1").ok(), Some(PipelineKind::CutSpec)); @@ -201,4 +59,3 @@ mod tests { assert!(kind_of("two:segments").is_err()); assert!(kind_of("campaign").is_err()); } -} diff --git a/crates/eureka-state/src/bin/huginn/trouble.rs b/crates/eureka-state/src/bin/huginn/trouble.rs index 3db33ea..65fe422 100644 --- a/crates/eureka-state/src/bin/huginn/trouble.rs +++ b/crates/eureka-state/src/bin/huginn/trouble.rs @@ -8,6 +8,7 @@ use std::fmt; use eureka_state::ClientError; +use eureka_state::door::Refusal; #[derive(Debug)] pub enum Trouble { @@ -77,3 +78,12 @@ impl From for Trouble { } } } + +impl From for Trouble { + fn from(refusal: Refusal) -> Self { + match refusal { + Refusal::Invalid(detail) => Self::InvalidInput(detail), + Refusal::Internal(detail) => Self::Internal(detail), + } + } +} diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs new file mode 100644 index 0000000..71a3d7a --- /dev/null +++ b/crates/eureka-state/src/door.rs @@ -0,0 +1,182 @@ +//! The text door: agent-written JSON becomes typed values here, strictly, and +//! nowhere else. The store's decode stays lenient (serde ignores unknown +//! keys), so a misspelled key would be dropped and the batch committed +//! without it. The door refuses instead: every key path present in the input +//! with a value and absent from the typed value's re-serialisation is named in +//! the refusal. Paths only, never values. +//! +//! Both consumers, the `huginn` CLI and the MCP server, admit through this +//! door and no other. + +use std::fmt; + +use cultnet_rs::Selection; +use huginn_mind::eureka_pipeline::PipelineDocument; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, error::Category}; + +/// Why the door did not open. +#[derive(Debug, PartialEq, Eq)] +pub enum Refusal { + /// The input is not what the schema describes; the detail names where, never what. + Invalid(String), + /// The door's own fault. + Internal(String), +} + +impl Refusal { + fn invalid(detail: impl Into) -> Self { + Self::Invalid(detail.into()) + } +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (Self::Invalid(detail) | Self::Internal(detail)) = self; + f.write_str(detail) + } +} + +/// A batch: a JSON array of `{kind, value}` documents, or one such object. +pub fn documents(text: &str) -> Result, Refusal> { + let value = parse(text)?; + let (items, typed): (&[Value], Vec) = match &value { + Value::Array(items) => (items.as_slice(), objects(items).and_then(|()| from_text(text, "documents"))?), + Value::Object(_) => (std::slice::from_ref(&value), vec![from_text(text, "documents[0]")?]), + _ => return Err(Refusal::invalid("the documents must be a JSON array of {kind, value} objects")), + }; + let kept = reserialise(&typed, "documents")?; + let mut lost = Vec::new(); + for (index, (given, held)) in items.iter().zip(&kept).enumerate() { + dropped(given, held, &format!("documents[{index}]"), &mut lost); + } + refuse_lost(&lost)?; + Ok(typed) +} + +pub fn selection(text: &str) -> Result { + let value = parse(text)?; + if !value.is_object() { + return Err(Refusal::invalid("selection must be a JSON object")); + } + let typed: Selection = from_text(text, "selection")?; + let kept = reserialise(std::slice::from_ref(&typed), "selection")?; + let mut lost = Vec::new(); + dropped(&value, &kept[0], "selection", &mut lost); + refuse_lost(&lost)?; + Ok(typed) +} + +pub fn selection_value(value: &Value) -> Result { + selection(&value.to_string()) +} + +fn parse(text: &str) -> Result { + serde_json::from_str(text).map_err(|error| Refusal::invalid(format!("not JSON: {}", place(&error)))) +} + +/// Where and what kind of failure a decode hit, never what the input said: +/// serde's own message quotes the offending value whole. +fn place(error: &serde_json::Error) -> String { + let category = match error.classify() { + Category::Io => "unreadable input", + Category::Syntax => "syntax", + Category::Data => "a value of the wrong type, an unknown variant or a missing field", + Category::Eof => "unexpected end", + }; + format!("{category} at line {} column {}", error.line(), error.column()) +} + +/// Serde would also read a struct from an array, positionally, so only an +/// object is a document or a selection. +fn objects(items: &[Value]) -> Result<(), Refusal> { + match items.iter().position(|item| !item.is_object()) { + None => Ok(()), + Some(index) => Err(Refusal::invalid(format!("documents[{index}] must be a JSON object"))), + } +} + +/// The typed value of the text, decoded from the text itself so the failure +/// has a real line and column. +fn from_text(text: &str, at: &str) -> Result { + serde_json::from_str(text).map_err(|error| Refusal::invalid(format!("{at} does not decode: {}", place(&error)))) +} + +/// What the typed values hold, as the JSON items to compare with the input. +fn reserialise(typed: &[T], at: &str) -> Result, Refusal> { + typed + .iter() + .map(|item| serde_json::to_value(item).map_err(|_| Refusal::Internal(format!("{at} does not re-serialise")))) + .collect() +} + +fn refuse_lost(paths: &[String]) -> Result<(), Refusal> { + if paths.is_empty() { + Ok(()) + } else { + Err(Refusal::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", ")))) + } +} + +/// The paths of `input` that carry a value and are absent from `kept`. +fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec) { + match (input, kept) { + (Value::Object(given), Value::Object(held)) => { + for (key, value) in given { + let here = format!("{path}.{key}"); + match held.get(key) { + Some(held) => dropped(value, held, &here, out), + None if !empty(value) => out.push(here), + None => {} + } + } + } + (Value::Array(given), Value::Array(held)) => { + for (index, (value, held)) in given.iter().zip(held).enumerate() { + dropped(value, held, &format!("{path}[{index}]"), out); + } + } + _ => {} + } +} + +/// A value whose loss changes nothing: the spellings of "not given". +fn empty(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(text) => text.is_empty(), + Value::Array(items) => items.is_empty(), + Value::Object(fields) => fields.is_empty(), + Value::Bool(_) | Value::Number(_) => false, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn paths(input: &Value, kept: &Value) -> Vec { + let mut out = Vec::new(); + dropped(input, kept, "d", &mut out); + out.sort(); + out + } + + #[test] + fn a_dropped_key_with_a_value_is_named_by_path() { + let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 }); + let kept = json!({ "a": { "b": [ { "c": 1 } ] } }); + assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]); + } + + #[test] + fn a_dropped_key_without_a_value_is_not_a_loss() { + let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false }); + assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]); + } + +} +} diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs index e94cd0b..0897a82 100644 --- a/crates/eureka-state/src/lib.rs +++ b/crates/eureka-state/src/lib.rs @@ -30,6 +30,7 @@ use huginn_mind::eureka_pipeline::Slug; use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES, PipelineProvenance}; use serde_json::Value; +pub mod door; pub mod hold; /// The correlation key of the one request a session carries. diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index 7f9c7b7..19b311b 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -21,6 +21,7 @@ use std::sync::Arc; use std::time::Duration; use cultnet_rs::Selection; +use eureka_state::door::{self, Refusal}; use eureka_state::{ClientError, HuginnClient}; use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineRef, Short, Slug}; use huginn_mind::{ @@ -70,7 +71,9 @@ struct AdmitInput { /// 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, + /// Decoded by the text door, which refuses a key the schema does not know. + #[schemars(with = "Vec")] + documents: Value, } #[derive(Deserialize, JsonSchema)] @@ -108,6 +111,8 @@ enum Trouble { Misconfigured(String), Client(ClientError), Internal(String), + /// Input the schema does not describe, refused by the text door before any call. + Invalid(String), } impl Trouble { @@ -125,6 +130,7 @@ impl Trouble { } Self::Client(ClientError::Unencodable { detail }) => json!({ "error": "Unencodable", "detail": detail }), Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), + Self::Invalid(detail) => json!({ "error": "InvalidInput", "detail": detail }), } } } @@ -260,6 +266,11 @@ impl EurekaState { 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 { + let documents = match door::documents(&input.documents.to_string()) { + Ok(documents) => documents, + Err(Refusal::Invalid(detail)) => return Self::replied(Err(Trouble::Invalid(detail))), + Err(Refusal::Internal(detail)) => return Self::replied(Err(Trouble::Internal(detail))), + }; self.answer(move |instance| { HuginnMindRequest::Admit(PipelineAdmissionBatch { instance, @@ -269,7 +280,7 @@ impl EurekaState { session: input.session, tool: Short(TOOL.into()), }, - documents: input.documents, + documents, }) }) .await diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index ef9b54a..7e0fb83 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -607,3 +607,48 @@ fn admit_carries_the_leaf_key_refusal_with_its_field_and_value() { let (_, whoami) = mcp.call("whoami", json!({})); assert_eq!(whoami["status"]["receipts"], json!(2), "only the seed is committed: {whoami}"); } + +/// A research note is the record the operator asked to lose nothing of. The +/// MCP door is the strict one: keys the schema does not know are refused by +/// path, as a typed `InvalidInput`, and nothing is stored; the same note +/// without them is committed. +#[test] +fn admit_refuses_keys_the_schema_does_not_know_and_stores_nothing() { + use huginn_mind::eureka_pipeline::{Evidence, EvidenceKind, Label, PipelineResearch, ResearchSource}; + + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let note = PipelineDocument::Research(PipelineResearch { + campaign: slug(CAMPAIGN), + pass: Label("p".into()), + label: Label("a".into()), + claim: "A fact.".into(), + detail: "".into(), + inferred: false, + sources: vec![ResearchSource::Observed { + evidence: Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() }, + }], + serves: vec![], + observed_on: Date("2026-11-04".into()), + }); + let clean = serde_json::to_value(¬e).unwrap(); + let mut extra = clean.clone(); + for key in ["recommendation", "status", "faculty"] { + extra["value"][key] = json!("CANARY-ONLY-IN-VALUES"); + } + let (error, refused) = mcp.call("admit", faculty_args(vec![extra])); + assert!(error, "{refused}"); + assert_eq!(refused["error"], json!("InvalidInput"), "{refused}"); + let detail = refused["detail"].as_str().unwrap(); + for key in ["recommendation", "status", "faculty"] { + assert!(detail.contains(&format!("documents[0].value.{key}")), "{detail}"); + } + assert!(!detail.contains("CANARY"), "paths only, never values: {detail}"); + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(2), "nothing stored: {whoami}"); + + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json(), clean])); + assert!(!error, "{admitted}"); + assert!(admitted.get("Committed").is_some(), "{admitted}"); +} -- 2.47.3 From 1aa317679615af9ad4030fd74dd2692415cb3c72 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:07:59 +0100 Subject: [PATCH 2/8] eureka-state: close the moved door's test modules Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/bin/huginn/door.rs | 1 + crates/eureka-state/src/door.rs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eureka-state/src/bin/huginn/door.rs b/crates/eureka-state/src/bin/huginn/door.rs index 949ecca..894d854 100644 --- a/crates/eureka-state/src/bin/huginn/door.rs +++ b/crates/eureka-state/src/bin/huginn/door.rs @@ -59,3 +59,4 @@ mod tests { assert!(kind_of("two:segments").is_err()); assert!(kind_of("campaign").is_err()); } +} diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 71a3d7a..26029e1 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -179,4 +179,3 @@ mod tests { } } -} -- 2.47.3 From 5b6305c5228c677ee27cb9d50bef8c2001e0c00f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:10:10 +0100 Subject: [PATCH 3/8] eureka-state door: tidy test module Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/door.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 26029e1..6f97c63 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -177,5 +177,4 @@ mod tests { let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false }); assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]); } - } -- 2.47.3 From eeeded257b6453400a7a38b6e30562be5f25bb29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:20:56 +0100 Subject: [PATCH 4/8] eureka-state door: drop the unused Display on Refusal Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/door.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 6f97c63..7e25f13 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -8,8 +8,6 @@ //! Both consumers, the `huginn` CLI and the MCP server, admit through this //! door and no other. -use std::fmt; - use cultnet_rs::Selection; use huginn_mind::eureka_pipeline::PipelineDocument; use serde::Serialize; @@ -31,13 +29,6 @@ impl Refusal { } } -impl fmt::Display for Refusal { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let (Self::Invalid(detail) | Self::Internal(detail)) = self; - f.write_str(detail) - } -} - /// A batch: a JSON array of `{kind, value}` documents, or one such object. pub fn documents(text: &str) -> Result, Refusal> { let value = parse(text)?; -- 2.47.3 From 51853c1ac127108a5f225f473bac6ac0b2a9f3c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:45:48 +0100 Subject: [PATCH 5/8] eureka-state door: a decode refusal names the field path, never a value Documents decode one by one through serde_path_to_error, so the refusal says documents[i].path and what is wrong, in the schema's words: a field name, the allowed variants, the expected type. Serde's own text quotes the input, so it is not passed through. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/eureka-state/Cargo.toml | 1 + crates/eureka-state/src/door.rs | 44 ++++++++++++++++++++++++++------ crates/eureka-state/tests/cli.rs | 34 ++++++++++++++++++------ crates/eureka-state/tests/mcp.rs | 37 +++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45d9d79..96fbfee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_path_to_error", "tempfile", "tokio", ] diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml index 986bbf4..2640908 100644 --- a/crates/eureka-state/Cargo.toml +++ b/crates/eureka-state/Cargo.toml @@ -40,6 +40,7 @@ rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_path_to_error = "0.1" tokio = { version = "1", features = ["rt", "macros", "io-std"] } # Tests run a real daemon in-process against a temporary mind, and the real diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 7e25f13..1ab5326 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -32,11 +32,17 @@ impl Refusal { /// A batch: a JSON array of `{kind, value}` documents, or one such object. pub fn documents(text: &str) -> Result, Refusal> { let value = parse(text)?; - let (items, typed): (&[Value], Vec) = match &value { - Value::Array(items) => (items.as_slice(), objects(items).and_then(|()| from_text(text, "documents"))?), - Value::Object(_) => (std::slice::from_ref(&value), vec![from_text(text, "documents[0]")?]), + let items: &[Value] = match &value { + Value::Array(items) => items, + Value::Object(_) => std::slice::from_ref(&value), _ => return Err(Refusal::invalid("the documents must be a JSON array of {kind, value} objects")), }; + objects(items)?; + let typed = items + .iter() + .enumerate() + .map(|(index, item)| decode(item, &format!("documents[{index}]"))) + .collect::, Refusal>>()?; let kept = reserialise(&typed, "documents")?; let mut lost = Vec::new(); for (index, (given, held)) in items.iter().zip(&kept).enumerate() { @@ -51,7 +57,7 @@ pub fn selection(text: &str) -> Result { if !value.is_object() { return Err(Refusal::invalid("selection must be a JSON object")); } - let typed: Selection = from_text(text, "selection")?; + let typed: Selection = decode(&value, "selection")?; let kept = reserialise(std::slice::from_ref(&typed), "selection")?; let mut lost = Vec::new(); dropped(&value, &kept[0], "selection", &mut lost); @@ -88,10 +94,32 @@ fn objects(items: &[Value]) -> Result<(), Refusal> { } } -/// The typed value of the text, decoded from the text itself so the failure -/// has a real line and column. -fn from_text(text: &str, at: &str) -> Result { - serde_json::from_str(text).map_err(|error| Refusal::invalid(format!("{at} does not decode: {}", place(&error)))) +/// The typed value of an input, or the path of the field that does not fit and +/// what is wrong with it. Serde's own messages quote the given value, so only +/// the schema's words are kept: a field name, the allowed variants, the +/// expected type. +fn decode(input: &Value, at: &str) -> Result { + serde_path_to_error::deserialize(input).map_err(|error| { + let path = error.path().to_string(); + let place = if path == "." { at.to_string() } else { format!("{at}.{path}") }; + Refusal::invalid(format!("{place}: {}", schema_words(&error.into_inner().to_string()))) + }) +} + +/// What a serde message says about the schema, without the value it was given. +fn schema_words(message: &str) -> String { + if message.starts_with("missing field `") { + return message.to_string(); + } + let expected = message.rfind(", expected ").map(|at| &message[at + ", expected ".len()..]); + match (message, expected) { + (m, Some(expected)) if m.starts_with("unknown variant") => format!("unknown variant, expected {expected}"), + (m, Some(expected)) if m.starts_with("invalid type") => format!("wrong type, expected {expected}"), + (m, Some(expected)) if m.starts_with("invalid value") || m.starts_with("invalid length") => { + format!("wrong value, expected {expected}") + } + _ => "a value the schema refuses".to_string(), + } } /// What the typed values hold, as the JSON items to compare with the input. diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index b3dbd06..5498769 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -30,6 +30,14 @@ use tempfile::TempDir; mod common; use common::*; +/// A well-formed research note, as a document. +fn research_json() -> Value { + json!({ "kind": "research", "value": { + "campaign": "eureka-state", "pass": "p", "label": "a", "claim": "A fact.", "detail": "", + "inferred": false, "sources": [{ "Observed": { "evidence": { "kind": "Test", "locator": "cargo test", "result": "ok" } } }], + "serves": [], "observed_on": "2026-11-04" } }) +} + struct Run { code: i32, out: String, @@ -413,7 +421,7 @@ fn a_falsy_unknown_value_is_still_a_loss() { fn a_document_that_does_not_decode_is_invalid_input() { let dir = tempfile::tempdir().unwrap(); let cases = [ - (json!([{ "kind": "nonsense", "value": {} }]), "documents does not decode"), + (json!([{ "kind": "nonsense", "value": {} }]), "documents[0]: unknown variant, expected"), (json!("text"), "JSON array"), (json!([[]]), "documents[0] must be a JSON object"), (json!([]), "1 to 64"), @@ -444,19 +452,29 @@ fn a_document_that_does_not_decode_never_echoes_its_values() { let unknown_kind = json!({ "kind": "CANARY-KIND", "value": {} }); let mut unknown_variant = serde_json::to_value(ruling(None)).unwrap(); unknown_variant["value"]["authority"] = json!("CANARY-VARIANT"); - for (n, document) in [string_for_number, long_string_for_list, unknown_kind, unknown_variant].into_iter().enumerate() { - let file = write(&dir, &format!("canary{n}.json"), &json!([document])); + let mut second_missing_claim = research_json(); + second_missing_claim["value"].as_object_mut().unwrap().remove("claim"); + let cases = [ + (vec![string_for_number], "documents[0].value.attempt: wrong type, expected"), + (vec![long_string_for_list], "documents[0].value.repos: wrong type, expected"), + (vec![unknown_kind], "documents[0]: unknown variant, expected"), + (vec![unknown_variant], "documents[0].value.authority: unknown variant, expected"), + (vec![campaign_json(), second_missing_claim], "documents[1].value: missing field `claim`"), + ]; + for (n, (documents, expected)) in cases.into_iter().enumerate() { + let file = write(&dir, &format!("canary{n}.json"), &json!(documents)); let ran = at(closed_port(), &admit_args(&file), None); assert_eq!(ran.code, 2, "case {n}: {}", ran.err); - assert!(ran.err.starts_with("InvalidInput: documents does not decode: "), "case {n}: {}", ran.err); - assert!(ran.err.contains(" at line 1 column "), "case {n}: {}", ran.err); + assert!(ran.err.starts_with("InvalidInput: "), "case {n}: {}", ran.err); + assert!(ran.err.contains(expected), "case {n} wants {expected}: {}", ran.err); + assert!(!ran.err.contains(" at line "), "no position in JSON the client never saw: {}", ran.err); assert!(!ran.err.contains("CANARY") && !ran.out.contains("CANARY"), "case {n}: {}", ran.err); - assert!(ran.err.len() < 300, "stderr is bounded whatever the input: {} bytes", ran.err.len()); + assert!(ran.err.len() < 600, "stderr is bounded whatever the input: {} bytes", ran.err.len()); } let ran = at(closed_port(), &["query", r#"{"limit": "CANARY-LIMIT"}"#], None); assert_eq!(ran.code, 2); - assert!(ran.err.starts_with("InvalidInput: selection does not decode: "), "{}", ran.err); - assert!(!ran.err.contains("CANARY") && ran.err.len() < 300, "{}", ran.err); + assert!(ran.err.starts_with("InvalidInput: selection.limit: wrong type, expected"), "{}", ran.err); + assert!(!ran.err.contains("CANARY") && ran.err.len() < 600, "{}", ran.err); let ran = at(closed_port(), &admit_args("-"), Some(r#"[{"kind": "campaign", "value": CANARY-SYNTAX}]"#)); assert_eq!(ran.code, 2); assert!(ran.err.starts_with("InvalidInput: not JSON: syntax at line 1 column "), "{}", ran.err); diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 7e0fb83..288f98c 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -652,3 +652,40 @@ fn admit_refuses_keys_the_schema_does_not_know_and_stores_nothing() { assert!(!error, "{admitted}"); assert!(admitted.get("Committed").is_some(), "{admitted}"); } + +/// A document that does not decode is refused naming the path of the field to +/// fix, with the batch index, and never a value of the input or a position in +/// JSON the client did not send. Nothing is stored. +#[test] +fn admit_names_the_path_of_a_field_that_does_not_decode_and_never_a_value() { + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let research = json!({ "kind": "research", "value": { + "campaign": CAMPAIGN, "pass": "p", "label": "a", "claim": "A fact.", "detail": "", + "inferred": false, "sources": [{ "Observed": { "evidence": { "kind": "Test", "locator": "cargo test", "result": "ok" } } }], + "serves": [], "observed_on": "2026-11-04" } }); + let mut missing_claim = research.clone(); + missing_claim["value"].as_object_mut().unwrap().remove("claim"); + let mut wrong_type = research.clone(); + wrong_type["value"]["sources"][0]["Observed"]["evidence"]["locator"] = json!(["CANARY-TYPE"]); + let mut wrong_variant = research.clone(); + wrong_variant["value"]["sources"][0]["Observed"]["evidence"]["kind"] = json!("CANARY-VARIANT"); + let unknown_kind = json!({ "kind": "CANARY-KIND", "value": {} }); + let cases = [ + (missing_claim, "documents[1].value: missing field `claim`"), + (wrong_type, "documents[1].value.sources[0].Observed.evidence.locator: wrong type, expected"), + (wrong_variant, "documents[1].value.sources[0].Observed.evidence.kind: unknown variant, expected"), + (unknown_kind, "documents[1]: unknown variant, expected"), + ]; + for (document, expected) in cases { + let (error, refused) = mcp.call("admit", faculty_args(vec![campaign_json(), document])); + assert!(error, "{refused}"); + assert_eq!(refused["error"], json!("InvalidInput"), "{refused}"); + let detail = refused["detail"].as_str().unwrap(); + assert!(detail.contains(expected), "wants {expected}: {detail}"); + assert!(!detail.contains(" at line ") && !detail.contains("CANARY"), "{detail}"); + } + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(2), "nothing stored: {whoami}"); +} -- 2.47.3 From b63de715ae8475df2e2f53207217f67e602fb79a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:47:07 +0100 Subject: [PATCH 6/8] eureka-state tests: an unknown kind names documents[i].kind Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/tests/cli.rs | 4 ++-- crates/eureka-state/tests/mcp.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 5498769..60bdc28 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -421,7 +421,7 @@ fn a_falsy_unknown_value_is_still_a_loss() { fn a_document_that_does_not_decode_is_invalid_input() { let dir = tempfile::tempdir().unwrap(); let cases = [ - (json!([{ "kind": "nonsense", "value": {} }]), "documents[0]: unknown variant, expected"), + (json!([{ "kind": "nonsense", "value": {} }]), "documents[0].kind: unknown variant, expected"), (json!("text"), "JSON array"), (json!([[]]), "documents[0] must be a JSON object"), (json!([]), "1 to 64"), @@ -457,7 +457,7 @@ fn a_document_that_does_not_decode_never_echoes_its_values() { let cases = [ (vec![string_for_number], "documents[0].value.attempt: wrong type, expected"), (vec![long_string_for_list], "documents[0].value.repos: wrong type, expected"), - (vec![unknown_kind], "documents[0]: unknown variant, expected"), + (vec![unknown_kind], "documents[0].kind: unknown variant, expected"), (vec![unknown_variant], "documents[0].value.authority: unknown variant, expected"), (vec![campaign_json(), second_missing_claim], "documents[1].value: missing field `claim`"), ]; diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 288f98c..09878c1 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -676,7 +676,7 @@ fn admit_names_the_path_of_a_field_that_does_not_decode_and_never_a_value() { (missing_claim, "documents[1].value: missing field `claim`"), (wrong_type, "documents[1].value.sources[0].Observed.evidence.locator: wrong type, expected"), (wrong_variant, "documents[1].value.sources[0].Observed.evidence.kind: unknown variant, expected"), - (unknown_kind, "documents[1]: unknown variant, expected"), + (unknown_kind, "documents[1].kind: unknown variant, expected"), ]; for (document, expected) in cases { let (error, refused) = mcp.call("admit", faculty_args(vec![campaign_json(), document])); -- 2.47.3 From 27ab1fcbcff6675f8dae941064f63673d4e9fbd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 22:58:42 +0100 Subject: [PATCH 7/8] eureka-state door: pin schema_words by behaviour Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/door.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 1ab5326..9557e39 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -196,4 +196,21 @@ mod tests { let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false }); assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]); } + + #[test] + fn schema_words_keep_the_schema_and_drop_the_given_value() { + assert_eq!(schema_words("missing field `claim`"), "missing field `claim`"); + assert_eq!( + schema_words("unknown variant `CANARY`, expected one of `a`, `b`"), + "unknown variant, expected one of `a`, `b`" + ); + assert_eq!(schema_words("invalid type: string \"CANARY\", expected a sequence"), "wrong type, expected a sequence"); + assert_eq!(schema_words("invalid value: integer `9`, expected u8"), "wrong value, expected u8"); + assert_eq!(schema_words("invalid length 3, expected a tuple of size 2"), "wrong value, expected a tuple of size 2"); + // A given value that itself says ", expected " is still not echoed past the last one. + assert_eq!(schema_words("invalid type: string \"x, expected CANARY\", expected a map"), "wrong type, expected a map"); + // A custom message may quote anything; none of it is passed on. + assert_eq!(schema_words("CANARY is not a slug"), "a value the schema refuses"); + assert_eq!(schema_words("unknown variant `CANARY`"), "a value the schema refuses"); + } } -- 2.47.3 From 4a7bb5fafcb6e2ee29993e39e26a3b1999c76b26 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 4 Nov 2026 23:03:19 +0100 Subject: [PATCH 8/8] eureka-state door: a custom message with an expected clause is not passed on Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/door.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/eureka-state/src/door.rs b/crates/eureka-state/src/door.rs index 9557e39..d1f06c5 100644 --- a/crates/eureka-state/src/door.rs +++ b/crates/eureka-state/src/door.rs @@ -210,6 +210,7 @@ mod tests { // A given value that itself says ", expected " is still not echoed past the last one. assert_eq!(schema_words("invalid type: string \"x, expected CANARY\", expected a map"), "wrong type, expected a map"); // A custom message may quote anything; none of it is passed on. + assert_eq!(schema_words("CANARY, expected a slug"), "a value the schema refuses"); assert_eq!(schema_words("CANARY is not a slug"), "a value the schema refuses"); assert_eq!(schema_words("unknown variant `CANARY`"), "a value the schema refuses"); } -- 2.47.3