The MCP admit goes through the one strict door; refusals name the field #5
9 changed files with 356 additions and 157 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -647,6 +647,7 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
217
crates/eureka-state/src/door.rs
Normal file
217
crates/eureka-state/src/door.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(¬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}");
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue