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<PipelineDocument>) 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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-04 22:06:41 +01:00
commit e808c619fd
6 changed files with 256 additions and 150 deletions

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));
@ -201,4 +59,3 @@ mod tests {
assert!(kind_of("two:segments").is_err());
assert!(kind_of("campaign").is_err());
}
}

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,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<String>) -> 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<Vec<PipelineDocument>, Refusal> {
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(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<Selection, Refusal> {
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, 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 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, Refusal> {
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<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()]);
}
}
}

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

@ -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(&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}");
}