Huginn/crates/huginn-mind/src/envelope.rs
Meta Vens ea28b8cbc0 Decoder reports field path and error class, never a caller value; status says Unreadable
The shared request decoder (envelope.rs) no longer quotes decoder text or
envelope fields: a refusal names the field path and the class. MindStatus.persona
is PersonaStatus {Absent, Stored, Unreadable}, so whoami answers in the persona
door's own terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:32:48 +02:00

327 lines
15 KiB
Rust

//! CultNet's operation envelope around the mind's vocabulary. Pure functions:
//! a request goes out as `cultnet.operation_request.v0` and an answer comes
//! back as `cultnet.operation_response.v0`, both carrying base64 of named
//! MessagePack, which is what the C# reference and Eve's plugin ABI speak.
//!
//! A message this module cannot decode is a typed failure with a code, never
//! a transport error and never a call into a mind. `payload_encoding` is
//! CultNet's own check in `validate_message` and is not re-checked here.
//! `source_runtime_id` and `target_runtime_id` on a request are read by
//! nothing: ruling 18 says an identity is declared in the payload, not
//! granted by the envelope.
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use cultnet_rs::CultNetMessage;
use crate::wire::{HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA, MIND_SERVICE_ID};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The C# reference's `CultNetOperationServer.FailureSchemaId`. The Rust
/// `cultnet-rs` publishes no type for it, so this two-field mirror carries the
/// reference's keys; parity belongs in CultLib.
pub const FAILURE_SCHEMA: &str = "gamecult.cultnet.operation_failure.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct OperationFailure {
pub code: String,
pub message: String,
}
impl OperationFailure {
fn new(code: &str, message: impl Into<String>) -> Self {
Self { code: code.into(), message: message.into() }
}
}
fn encode_payload<T: Serialize>(value: &T) -> Result<String> {
Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?))
}
/// One request, addressed to the mind service and naming its own operation.
pub fn encode_request(
message_id: &str,
request: &HuginnMindRequest,
source_runtime_id: Option<String>,
) -> Result<CultNetMessage> {
Ok(CultNetMessage::OperationRequest {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
operation: request.operation().to_string(),
payload_schema: MIND_REQUEST_SCHEMA.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(request)?,
source_runtime_id,
target_runtime_id: None,
})
}
/// The message, or the failure that says why it is not a request of this
/// service. The envelope's `operation` string is checked against the decoded
/// request rather than trusted, so a payload cannot arrive under another
/// operation's name.
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
let CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, payload, .. } = message
else {
return Err(OperationFailure::new(
"not-an-operation-request",
"huginn.mind reads cultnet.operation_request.v0",
));
};
if service_id != MIND_SERVICE_ID {
return Err(OperationFailure::new("wrong-service", format!("the service is not {MIND_SERVICE_ID}")));
}
if payload_schema != MIND_REQUEST_SCHEMA {
return Err(OperationFailure::new(
"wrong-payload-schema",
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
));
}
let bytes = STANDARD
.decode(payload)
.map_err(|_| OperationFailure::new("payload-not-base64", "the payload is not standard base64"))?;
let request = read_request(&bytes)?;
if operation != request.operation() {
return Err(OperationFailure::new(
"operation-mismatch",
format!("the envelope's operation is not the payload's {}", request.operation()),
));
}
Ok((message_id.clone(), request))
}
/// The payload as a request, or a failure naming the field path and the class
/// of error and nothing the caller sent: a decoder's own text quotes the
/// offending value (an unknown variant, an integer), so no decoder text
/// leaves this function.
fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
use rmp_serde::decode::Error as Decode;
let mut decoder = rmp_serde::Deserializer::new(bytes);
serde_path_to_error::deserialize(&mut decoder).map_err(|error| {
let class = match error.inner() {
Decode::InvalidMarkerRead(_) | Decode::InvalidDataRead(_) => "truncated",
Decode::TypeMismatch(_) => "wrong-type",
Decode::OutOfRange => "out-of-range",
Decode::LengthMismatch(_) => "wrong-length",
Decode::Uncategorized(_) | Decode::Syntax(_) => "invalid-value",
Decode::Utf8Error(_) => "invalid-text",
Decode::DepthLimitExceeded => "too-deep",
};
let path = error.path().to_string();
let at = if path == "." { "the payload".to_string() } else { format!("field {path}") };
OperationFailure::new("payload-not-a-request", format!("{at}: {class}"))
})
}
/// One answer, whose status the response derives.
pub fn encode_response(
message_id: &str,
operation: &str,
response: &HuginnMindResponse,
source_runtime_id: &str,
) -> Result<CultNetMessage> {
Ok(CultNetMessage::OperationResponse {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
operation: operation.to_string(),
status: response.status().to_string(),
payload_schema: MIND_RESPONSE_SCHEMA.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(response)?,
diagnostics: vec![],
source_runtime_id: Some(source_runtime_id.to_string()),
})
}
/// The answer to an envelope that never reached a mind. Infallible: the
/// failure is two strings and the daemon must always be able to say no.
pub fn encode_failure(
message_id: &str,
operation: &str,
failure: &OperationFailure,
source_runtime_id: &str,
) -> CultNetMessage {
CultNetMessage::OperationResponse {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
operation: operation.to_string(),
status: "rejected".to_string(),
payload_schema: FAILURE_SCHEMA.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: STANDARD.encode(rmp_serde::to_vec_named(failure).expect("two strings encode")),
diagnostics: vec![failure.code.clone()],
source_runtime_id: Some(source_runtime_id.to_string()),
}
}
/// The client's side: the correlation key and either the mind's answer or the
/// failure that says the envelope never reached one.
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
let CultNetMessage::OperationResponse { message_id, payload_schema, payload, .. } = message else {
anyhow::bail!("huginn.mind answers with cultnet.operation_response.v0");
};
let bytes = STANDARD.decode(payload)?;
let answer = match payload_schema.as_str() {
MIND_RESPONSE_SCHEMA => Ok(rmp_serde::from_slice::<HuginnMindResponse>(&bytes)?),
FAILURE_SCHEMA => Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?),
other => anyhow::bail!("{other} is neither {MIND_RESPONSE_SCHEMA} nor {FAILURE_SCHEMA}"),
};
Ok((message_id.clone(), answer))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::{INSTANCE, OTHER_INSTANCE as OTHER, instance, provenance, slug};
use crate::wire::MindStatus;
use crate::{Faculty, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome};
fn refusal() -> MindRefusal {
MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() }
}
/// A refusal is an answer: it rides the response schema with a `rejected`
/// status and decodes back to the same value. A conflict and a replay are
/// answers about the mind, not rejections of the request.
#[test]
fn a_refusal_is_a_typed_response_not_a_transport_failure() {
let cases = [
(HuginnMindResponse::Refused(refusal()), "rejected"),
(HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal())), "rejected"),
(
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed {
receipt_id: "r1".into(),
committed_at: "2026-09-16T12:00:00Z".into(),
writes: vec![],
}),
"accepted",
),
(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r1".into() }), "accepted"),
(HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities: vec![] }), "accepted"),
(
HuginnMindResponse::Whoami(MindStatus {
instance: slug(INSTANCE),
schema_epoch: "epiphany.pipeline.epoch.v2".into(),
documents: 0,
receipts: 0,
index: crate::wire::IndexStatus::Current,
persona: crate::wire::PersonaStatus::Absent,
}),
"accepted",
),
];
for (response, expected) in cases {
let message = encode_response("m-1", "admit", &response, "huginn-yggdrasil").unwrap();
let CultNetMessage::OperationResponse { status, payload_schema, diagnostics, .. } = &message else {
panic!("not an operation response");
};
assert_eq!(status, expected, "{response:?}");
assert_eq!(payload_schema, MIND_RESPONSE_SCHEMA);
assert!(diagnostics.is_empty());
assert_eq!(decode_response(&message).unwrap(), ("m-1".into(), Ok(response)));
}
let failure = OperationFailure::new("wrong-service", "odin.catalog is not huginn.mind");
let message = encode_failure("m-2", "query", &failure, "huginn-yggdrasil");
let CultNetMessage::OperationResponse { status, payload_schema, diagnostics, .. } = &message else {
panic!("not an operation response");
};
assert_eq!((status.as_str(), payload_schema.as_str()), ("rejected", FAILURE_SCHEMA));
assert_eq!(diagnostics, &vec!["wrong-service".to_string()]);
assert_eq!(decode_response(&message).unwrap(), ("m-2".into(), Err(failure)));
}
fn request_message(service_id: &str, operation: &str, schema: &str, payload: String) -> CultNetMessage {
CultNetMessage::OperationRequest {
message_id: "m-9".into(),
service_id: service_id.into(),
operation: operation.into(),
payload_schema: schema.into(),
payload_encoding: "messagepack-base64".into(),
payload,
source_runtime_id: None,
target_runtime_id: None,
}
}
/// The payload of `request` with the value at `path` replaced, encoded the
/// way a client does.
fn payload_with(request: &HuginnMindRequest, path: &[&str], bad: serde_json::Value) -> String {
let mut value = serde_json::to_value(request).unwrap();
let mut at = &mut value;
for key in path {
at = at.get_mut(*key).unwrap_or_else(|| panic!("no {key} in the request"));
}
*at = bad;
STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap())
}
/// A refusal of the decoder names the field and the class of error and
/// nothing the caller sent: not an unknown variant, not an integer, not
/// an envelope field, not a byte of bad base64. Over the persona wire and
/// over the pipeline's, because the decoder is one.
#[test]
fn the_decoder_never_echoes_what_the_caller_sent() {
const CANARY: &str = "CANARY-5c0de1-do-not-echo";
const NUMBER: i64 = 90817263;
let put = HuginnMindRequest::PersonaPut {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
state: serde_json::json!({}),
expected_updated_at: None,
};
let admit = HuginnMindRequest::Admit(PipelineAdmissionBatch {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
documents: vec![instance(INSTANCE)],
});
let cases = [
(&put, vec!["PersonaPut", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
(&put, vec!["PersonaPut", "provenance", "agent"], serde_json::json!(NUMBER), "provenance.agent"),
(&put, vec!["PersonaPut", "expected_updated_at"], serde_json::json!(NUMBER), "expected_updated_at"),
(&put, vec!["PersonaPut", "instance"], serde_json::json!(NUMBER), "instance"),
(&admit, vec!["Admit", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
(&admit, vec!["Admit", "instance"], serde_json::json!(NUMBER), "instance"),
];
for (request, path, bad, field) in cases {
let message = request_message(MIND_SERVICE_ID, request.operation(), MIND_REQUEST_SCHEMA, payload_with(request, &path, bad));
let failure = decode_request(&message).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request");
assert!(failure.message.contains(field), "{field} is named: {}", failure.message);
assert!(failure.message.ends_with(": invalid-value"), "the class is named: {}", failure.message);
for echoed in [CANARY.to_string(), NUMBER.to_string()] {
assert!(!failure.message.contains(&echoed), "{echoed} came back: {}", failure.message);
}
}
let good = payload_with(&put, &["PersonaPut", "expected_updated_at"], serde_json::Value::Null);
let envelope_cases = [
request_message(CANARY, "persona_put", MIND_REQUEST_SCHEMA, good.clone()),
request_message(MIND_SERVICE_ID, "persona_put", CANARY, good.clone()),
request_message(MIND_SERVICE_ID, CANARY, MIND_REQUEST_SCHEMA, good),
request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, format!("{CANARY}!!")),
];
for message in envelope_cases {
let failure = decode_request(&message).unwrap_err();
assert!(!failure.message.contains(CANARY), "{} came back: {}", failure.code, failure.message);
}
}
/// The round trip the client and the daemon share, and the one code that
/// `serve::answer` cannot reach because it answers a foreign message with
/// `Error` instead.
#[test]
fn a_request_round_trips_and_a_foreign_message_is_not_a_request() {
let request = HuginnMindRequest::Admit(PipelineAdmissionBatch {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
documents: vec![instance(INSTANCE)],
});
let message = encode_request("m-3", &request, Some("eureka-state".into())).unwrap();
assert_eq!(decode_request(&message).unwrap(), ("m-3".into(), request));
let foreign = CultNetMessage::Error { error: "not for you".into(), code: None, details: None };
assert_eq!(decode_request(&foreign).unwrap_err().code, "not-an-operation-request");
}
}