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