The MCP admit goes through the one strict door; refusals name the field #5

Merged
eureka merged 8 commits from hands/eureka-body-typed-research into main 2026-11-04 22:16:42 +00:00
9 changed files with 356 additions and 157 deletions

1
Cargo.lock generated
View file

@ -647,6 +647,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"serde_path_to_error",
"tempfile",
"tokio",
]

View file

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

View file

@ -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<String, Trouble> {
Ok(text)
}
/// A batch: a JSON array of `{kind, value}` documents, or one such object.
pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Trouble> {
let value = parse(text)?;
let (items, typed): (&[Value], Vec<PipelineDocument>) = 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<Selection, Trouble> {
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, Trouble> {
selection(&value.to_string())
}
/// The kind a pipeline id names in its middle segment (`<root>:<kind>:<local>`).
pub fn kind_of(id: &str) -> Result<PipelineKind, Trouble> {
let mut segments = id.splitn(3, ':');
@ -88,112 +48,10 @@ pub fn kind_of(id: &str) -> Result<PipelineKind, Trouble> {
.ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)"))
}
fn parse(text: &str) -> Result<Value, Trouble> {
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<T: DeserializeOwned>(text: &str, at: &str) -> Result<T, Trouble> {
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<T: Serialize>(typed: &[T], at: &str) -> Result<Vec<Value>, 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<String>) {
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<String> {
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));

View file

@ -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<ClientError> for Trouble {
}
}
}
impl From<Refusal> for Trouble {
fn from(refusal: Refusal) -> Self {
match refusal {
Refusal::Invalid(detail) => Self::InvalidInput(detail),
Refusal::Internal(detail) => Self::Internal(detail),
}
}
}

View file

@ -0,0 +1,217 @@
//! 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 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<String>) -> Self {
Self::Invalid(detail.into())
}
}
/// A batch: a JSON array of `{kind, value}` documents, or one such object.
pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Refusal> {
let value = parse(text)?;
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::<Result<Vec<PipelineDocument>, Refusal>>()?;
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<Selection, Refusal> {
let value = parse(text)?;
if !value.is_object() {
return Err(Refusal::invalid("selection must be a JSON object"));
}
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);
refuse_lost(&lost)?;
Ok(typed)
}
pub fn selection_value(value: &Value) -> Result<Selection, Refusal> {
selection(&value.to_string())
}
fn parse(text: &str) -> Result<Value, Refusal> {
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 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<T: DeserializeOwned>(input: &Value, at: &str) -> Result<T, Refusal> {
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.
fn reserialise<T: Serialize>(typed: &[T], at: &str) -> Result<Vec<Value>, 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<String>) {
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<String> {
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 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, 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");
}
}

View file

@ -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.

View file

@ -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<PipelineDocument>,
/// Decoded by the text door, which refuses a key the schema does not know.
#[schemars(with = "Vec<PipelineDocument>")]
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<AdmitInput>) -> 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

View file

@ -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].kind: 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].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`"),
];
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);

View file

@ -607,3 +607,85 @@ 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(&note).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}");
}
/// 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].kind: 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}");
}