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:
parent
869edfdf28
commit
e808c619fd
6 changed files with 256 additions and 150 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
crates/eureka-state/src/door.rs
Normal file
182
crates/eureka-state/src/door.rs
Normal 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()]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue