Desk wire, part 2: the envelope (wire names mind/authority/subject/deciders, deciders 1..64 unique, generic cores, schemas) #35

Merged
eureka merged 4 commits from eureka-substrate/desk-wire-envelope into main 2026-11-10 20:47:56 +00:00
6 changed files with 2374 additions and 46 deletions

View file

@ -10,12 +10,12 @@
//! stays the only owner of "counts": the response builder calls it at the head
//! the response is read at, and nothing here stores it. Huginn writes
//! `gamecult.desk_answer.v0` and its own refusals; the stale-fork display
//! and the answer result are Bifrost's to attach and write. The envelope, the published
//! schemas, the reads and the socket are other cuts.
//! and the answer result are Bifrost's to attach and write. The request type, its
//! constants and the published schemas are here; the reads and the socket are other cuts.
use chrono::{DateTime, Utc};
use eureka_pipeline::{
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Words,
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -55,7 +55,7 @@ fn required<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(deserializer:
}
/// `gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the
/// shape step of `Mind::answer_desk` judges it. The subject and the attribution are the
/// shape step of `Mind::answer_desk` judges it. The subject and the authority are the
/// door's context and are no field here.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@ -180,6 +180,130 @@ pub enum DeskResponse {
Refused(MindRefusal),
}
impl DeskResponse {
/// The envelope's status: `rejected` exactly when nothing was stored or the Mind refused.
pub fn status(&self) -> &'static str {
match self {
Self::Answered { .. } => "accepted",
Self::NotStored(_) | Self::Refused(_) => "rejected",
}
}
}
/// The envelope's `service_id` for the desk door.
pub const DESK_SERVICE_ID: &str = "huginn.desk";
pub const DESK_REQUEST_SCHEMA: &str = "huginn.desk_request.v1";
pub const DESK_RESPONSE_SCHEMA: &str = "huginn.desk_response.v1";
/// The published schemas, derived from `DeskRequest` and `DeskResponse` and pinned byte for byte
/// by `published_desk_schemas_match_derivation`.
pub const DESK_REQUEST_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_request.v1.schema.json");
pub const DESK_RESPONSE_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_response.v1.schema.json");
/// The frame bounds the door enforces, on the encoded CultNet message.
pub const DESK_MAX_REQUEST_BYTES: usize = 65536;
pub const DESK_MAX_RESPONSE_BYTES: usize = 65536;
/// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
pub const DESK_DECIDERS_MAX: usize = 64;
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` subjects, none twice. A
/// bad list is the decoder's refusal, which names the field and never quotes a subject.
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> {
struct List;
impl<'de> serde::de::Visitor<'de> for List {
type Value = Vec<Short>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a list of distinct deciders")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Short>, A::Error> {
let mut list = Vec::new();
while let Some(subject) = seq.next_element::<Short>()? {
if list.len() == DESK_DECIDERS_MAX {
return Err(serde::de::Error::custom("too many deciders"));
}
if list.contains(&subject) {
return Err(serde::de::Error::custom("a decider is listed twice"));
}
list.push(subject);
}
if list.is_empty() {
return Err(serde::de::Error::custom("no deciders"));
}
Ok(list)
}
}
deserializer.deserialize_seq(List)
}
fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "array",
"items": generator.subschema_for::<Short>(),
"minItems": 1,
"maxItems": DESK_DECIDERS_MAX,
"uniqueItems": true
})
}
/// Every request the desk door answers. The fields are the wire names: the `mind` addressed
/// (the persona and pipeline requests say `instance`; the sets are separate), the verified
/// `subject` and `authority` Bifrost vouches for (door context, never body fields), and the
/// `deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside the set refuses
/// the whole payload. The reads answer in follow-up desk-wire-reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub enum DeskRequest {
Answer {
mind: Slug,
subject: Short,
authority: DeskAuthority,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
request: ForkAnswerRequest,
},
Forks {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
},
Fork {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
fork_id: String,
},
}
impl DeskRequest {
/// The envelope's `operation`.
pub fn operation(&self) -> &'static str {
match self {
Self::Answer { .. } => "answer",
Self::Forks { .. } => "forks",
Self::Fork { .. } => "fork",
}
}
/// The mind the request addresses, for `Mind::require_instance`.
pub fn mind(&self) -> &Slug {
match self {
Self::Answer { mind, .. } | Self::Forks { mind, .. } | Self::Fork { mind, .. } => mind,
}
}
pub fn deciders(&self) -> &[Short] {
match self {
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
}
}
}
// ---- the one conversion between her answer ids and the record keys ----
/// The key prefix of one fork's answers. The only place a desk answer key is spelled.
@ -324,17 +448,17 @@ impl<S: MindStore> Mind<S> {
/// One desk answer from the door: the wire request converted, the question gated, the answer
/// decided and stored (or replayed) by `answer_desk`, and the stored answer projected with the
/// derived count at the head it was read at. The flag is `true` when nothing was committed (a
/// replay); it is for the door and is never encoded. `subject` and `attribution` are the verified
/// replay); it is for the door and is never encoded. `subject` and `authority` are the verified
/// caller's, `deciders` the door's list.
pub fn desk_answer(
&mut self,
deciders: &[Short],
subject: Short,
attribution: DeskAuthority,
authority: DeskAuthority,
request: ForkAnswerRequest,
now: DateTime<Utc>,
) -> (DeskResponse, bool) {
match self.desk_response(deciders, subject, attribution, &request, now) {
match self.desk_response(deciders, subject, authority, &request, now) {
Ok(done) => done,
Err(refused) => (door_refusal(refused), false),
}
@ -344,7 +468,7 @@ impl<S: MindStore> Mind<S> {
&mut self,
deciders: &[Short],
subject: Short,
attribution: DeskAuthority,
authority: DeskAuthority,
request: &ForkAnswerRequest,
now: DateTime<Utc>,
) -> Result<(DeskResponse, bool), MindRefusal> {
@ -361,7 +485,7 @@ impl<S: MindStore> Mind<S> {
if unshowable {
return Ok((DeskResponse::NotStored(fork_unknown()), false));
}
let stamp = DeskStamp { answerer: subject, authority: attribution, deciders: deciders.to_vec() };
let stamp = DeskStamp { answerer: subject, authority, deciders: deciders.to_vec() };
let (key, replayed) = match self.answer_desk(stamp, sent, now) {
DeskAnswerOutcome::Stored { answer, .. } => (answer.0, false),
DeskAnswerOutcome::Replayed { answer, .. } => (answer.0, true),
@ -1380,7 +1504,7 @@ mod tests {
}
#[test]
fn the_attribution_is_stamped_and_never_decides() {
fn the_authority_is_stamped_and_never_decides() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
// The operator's own account, off the list, is no decider; a member on the list is.
@ -1390,4 +1514,98 @@ mod tests {
let (answer, _, _) = answered(on);
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_str()), ("Counted", DeskAuthority::Member, "m"));
}
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----
use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
fn list_of(n: usize) -> Vec<Short> {
(0..n).map(|at| s(&format!("d{at}"))).collect()
}
/// Each request kind of the desk, carrying `list`.
fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] {
[
DeskRequest::Answer { mind: slug(mind), subject: s("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
DeskRequest::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec() },
DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
]
}
/// The request as a client sends it and the door reads it.
fn through_the_door(request: &DeskRequest) -> Result<DeskRequest, OperationFailure> {
let message = encode_desk_request("m-1", request, None).unwrap();
decode_desk_request(&message).map(|(_, read)| read)
}
#[test]
fn deciders_are_at_most_sixty_four_per_call_on_answer_forks_and_fork() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let names = ["answer", "forks", "fork"];
for (at, sent) in desk_requests(INSTANCE, &list_of(64), &hash).into_iter().enumerate() {
let read = through_the_door(&sent).unwrap_or_else(|failure| panic!("{}: 64 deciders refused: {failure:?}", names[at]));
assert_eq!((read.operation(), read.deciders().len()), (names[at], 64));
assert_eq!(read, sent);
}
// 64 deciders through to the Mind: the subject is listed and counts.
let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&desk_requests(INSTANCE, &list_of(64), &hash)[0]).unwrap() else { panic!("not an answer") };
let (response, replayed) = mind.desk_answer(&deciders, subject, authority, request, now());
assert!(!replayed);
assert_eq!(answered(response).0.outcome, "Counted");
let (head_before, stored_before) = (head(&mind), stored_count(&mind, FORK));
for (at, sent) in desk_requests(INSTANCE, &list_of(65), &hash).into_iter().enumerate() {
let failure = through_the_door(&sent).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{}", names[at]);
assert!(failure.message.contains("deciders"), "{} names the field: {}", names[at], failure.message);
assert!(!failure.message.contains("d64"), "{} echoes a decider: {}", names[at], failure.message);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), (head_before, stored_before));
}
#[test]
fn an_empty_or_duplicated_decider_list_is_refused_at_decode_and_one_is_accepted() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK));
let mut twice = list_of(3);
twice.push(s("d1"));
for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
for sent in desk_requests(INSTANCE, &list, &hash) {
let failure = through_the_door(&sent).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{what} {}", sent.operation());
assert!(failure.message.contains("deciders") && !failure.message.contains("d1"), "{what} {}: {}", sent.operation(), failure.message);
}
}
// One decider is a list.
for sent in desk_requests(INSTANCE, &list_of(1), &hash) {
assert_eq!(through_the_door(&sent).unwrap(), sent);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
// The Mind's showability gate is the second line: an empty list handed to it directly
// still refuses the fork and stores nothing.
let refused = not_stored(mind.desk_answer(&[], s("d0"), DeskAuthority::Member, wire(FORK, &hash), now()).0);
assert_eq!(refused.code, ForkRefusalCode::ForkUnknown);
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
}
#[test]
fn a_request_for_another_mind_is_refused_and_stores_nothing() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK));
for sent in desk_requests(OTHER_INSTANCE, &list_of(4), &hash) {
let read = through_the_door(&sent).unwrap();
assert_eq!(
mind.require_instance(read.mind()),
Err(MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() })
);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
// The same request to the mind it names is answered.
let DeskRequest::Answer { mind: named, subject, authority, deciders, request } = through_the_door(&desk_requests(INSTANCE, &list_of(4), &hash)[0]).unwrap() else { panic!("not an answer") };
mind.require_instance(&named).unwrap();
assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now()).0).0.outcome, "Counted");
}
}

View file

@ -14,6 +14,7 @@ use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use cultnet_rs::CultNetMessage;
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse};
use crate::wire::{HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA, MIND_SERVICE_ID};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -39,17 +40,20 @@ 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(
/// One request to `service_id`, naming its own operation, its payload under `payload_schema`.
fn encode_operation_request<T: Serialize>(
message_id: &str,
request: &HuginnMindRequest,
service_id: &str,
payload_schema: &str,
operation: &str,
request: &T,
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(),
service_id: service_id.to_string(),
operation: operation.to_string(),
payload_schema: payload_schema.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(request)?,
source_runtime_id,
@ -59,43 +63,49 @@ pub fn encode_request(
/// 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
/// request (`operation_of`) rather than trusted, so a payload cannot arrive
/// under another operation's name.
fn decode_operation_request<T: serde::de::DeserializeOwned>(
message: &CultNetMessage,
service_id: &str,
payload_schema: &str,
operation_of: impl Fn(&T) -> &'static str,
) -> Result<(String, T), OperationFailure> {
let CultNetMessage::OperationRequest { message_id, service_id: sent_service, operation, payload_schema: sent_schema, payload, .. } = message
else {
return Err(OperationFailure::new(
"not-an-operation-request",
"huginn.mind reads cultnet.operation_request.v0",
format!("{service_id} 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 sent_service != service_id {
return Err(OperationFailure::new("wrong-service", format!("the service is not {service_id}")));
}
if payload_schema != MIND_REQUEST_SCHEMA {
if sent_schema != payload_schema {
return Err(OperationFailure::new(
"wrong-payload-schema",
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
format!("the payload schema is not {payload_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() {
let request = read_payload::<T>(&bytes)?;
let expected = operation_of(&request);
if operation != expected {
return Err(OperationFailure::new(
"operation-mismatch",
format!("the envelope's operation is not the payload's {}", request.operation()),
format!("the envelope's operation is not the payload's {expected}"),
));
}
Ok((message_id.clone(), request))
}
/// The payload as a request, or a failure naming the field path and the class
/// The payload as a `T`, 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> {
fn read_payload<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, 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| {
@ -114,19 +124,22 @@ fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
})
}
/// One answer, whose status the response derives.
pub fn encode_response(
/// One answer, with the status its caller derived.
fn encode_operation_response<T: Serialize>(
message_id: &str,
service_id: &str,
payload_schema: &str,
operation: &str,
response: &HuginnMindResponse,
status: &str,
response: &T,
source_runtime_id: &str,
) -> Result<CultNetMessage> {
Ok(CultNetMessage::OperationResponse {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
service_id: service_id.to_string(),
operation: operation.to_string(),
status: response.status().to_string(),
payload_schema: MIND_RESPONSE_SCHEMA.to_string(),
status: status.to_string(),
payload_schema: payload_schema.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(response)?,
diagnostics: vec![],
@ -136,15 +149,16 @@ pub fn encode_response(
/// 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(
fn encode_operation_failure(
message_id: &str,
service_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(),
service_id: service_id.to_string(),
operation: operation.to_string(),
status: "rejected".to_string(),
payload_schema: FAILURE_SCHEMA.to_string(),
@ -155,21 +169,78 @@ pub fn encode_failure(
}
}
/// 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>)> {
/// The client's side: the correlation key and either the service's answer or
/// the failure that says the envelope never reached one.
fn decode_operation_response<T: serde::de::DeserializeOwned>(
message: &CultNetMessage,
service_id: &str,
response_schema: &str,
) -> Result<(String, Result<T, OperationFailure>)> {
let CultNetMessage::OperationResponse { message_id, payload_schema, payload, .. } = message else {
anyhow::bail!("huginn.mind answers with cultnet.operation_response.v0");
anyhow::bail!("{service_id} 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}"),
let answer = if payload_schema == response_schema {
Ok(rmp_serde::from_slice::<T>(&bytes)?)
} else if payload_schema == FAILURE_SCHEMA {
Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?)
} else {
anyhow::bail!("{payload_schema} is neither {response_schema} nor {FAILURE_SCHEMA}")
};
Ok((message_id.clone(), answer))
}
/// One request, addressed to the mind service.
pub fn encode_request(
message_id: &str,
request: &HuginnMindRequest,
source_runtime_id: Option<String>,
) -> Result<CultNetMessage> {
encode_operation_request(message_id, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
}
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
decode_operation_request(message, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, HuginnMindRequest::operation)
}
pub fn encode_response(
message_id: &str,
operation: &str,
response: &HuginnMindResponse,
source_runtime_id: &str,
) -> Result<CultNetMessage> {
encode_operation_response(message_id, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
}
pub fn encode_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
encode_operation_failure(message_id, MIND_SERVICE_ID, operation, failure, source_runtime_id)
}
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
decode_operation_response(message, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA)
}
/// One request, addressed to the desk service.
pub fn encode_desk_request(message_id: &str, request: &DeskRequest, source_runtime_id: Option<String>) -> Result<CultNetMessage> {
encode_operation_request(message_id, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
}
pub fn decode_desk_request(message: &CultNetMessage) -> Result<(String, DeskRequest), OperationFailure> {
decode_operation_request(message, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, DeskRequest::operation)
}
pub fn encode_desk_response(message_id: &str, operation: &str, response: &DeskResponse, source_runtime_id: &str) -> Result<CultNetMessage> {
encode_operation_response(message_id, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
}
pub fn encode_desk_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
encode_operation_failure(message_id, DESK_SERVICE_ID, operation, failure, source_runtime_id)
}
pub fn decode_desk_response(message: &CultNetMessage) -> Result<(String, Result<DeskResponse, OperationFailure>)> {
decode_operation_response(message, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA)
}
#[cfg(test)]
mod tests {
use super::*;
@ -324,4 +395,282 @@ mod tests {
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");
}
// ---- the desk's instantiation of the same cores ----
use crate::desk_wire::{
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse,
ForkAnswer, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode, RefusalSchema,
};
use cultnet_rs::{CultNetWireContract, encode_cultnet_message_to_vec};
use eureka_pipeline::DeskAuthority;
fn desk_answer() -> DeskRequest {
DeskRequest::Answer {
mind: slug(INSTANCE),
subject: crate::fixtures::s("user-7"),
authority: DeskAuthority::Member,
deciders: vec![crate::fixtures::s("user-7")],
request: ForkAnswerRequest {
schema_version: crate::desk_wire::RequestSchema::V0,
fork_id: "eureka-state:question:Q1".into(),
options_hash: "0".repeat(64),
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
retracted: false,
retracts: None,
answered_at: "2026-11-10T12:00:00Z".into(),
request_id: "00000000-0000-4000-8000-000000000001".into(),
},
}
}
fn desk_requests() -> [(DeskRequest, &'static str); 3] {
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]);
[
(desk_answer(), "Answer"),
(DeskRequest::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone() }, "Forks"),
(DeskRequest::Fork { mind, subject, deciders, fork_id: "eureka-state:question:Q1".into() }, "Fork"),
]
}
/// The fields of a desk request ARE Libby's wire names, with no mapping layer between: `mind`,
/// `subject`, `authority`, `deciders`, `request`. The persona and pipeline requests keep
/// `instance`; the sets do not mix. A key outside the set, or one that would make the caller
/// its own authority (`answerer`, `attribution`), refuses the whole payload.
#[test]
fn desk_request_fields_are_the_wire_names_and_the_set_is_closed() {
let keys = |value: &serde_json::Value, variant: &str| {
let mut keys: Vec<String> = value[variant].as_object().unwrap().keys().cloned().collect();
keys.sort();
keys
};
let expected = [
("Answer", vec!["authority", "deciders", "mind", "request", "subject"]),
("Forks", vec!["deciders", "mind", "subject"]),
("Fork", vec!["deciders", "fork_id", "mind", "subject"]),
];
for ((request, variant), (named, names)) in desk_requests().iter().zip(expected) {
assert_eq!(*variant, named);
let value = serde_json::to_value(request).unwrap();
assert_eq!(keys(&value, variant), names, "{variant}");
let message = encode_desk_request("m-1", request, None).unwrap();
assert_eq!(decode_desk_request(&message).unwrap(), ("m-1".into(), request.clone()));
for extra in ["answerer", "attribution", "instance", "mood"] {
let mut bad = value.clone();
bad[*variant].as_object_mut().unwrap().insert(extra.into(), serde_json::json!(CANARY_KEY));
let payload = STANDARD.encode(rmp_serde::to_vec_named(&bad).unwrap());
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
let failure = decode_desk_request(&message).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{variant} with {extra}");
assert!(failure.message.contains(variant), "{variant} with {extra}: {}", failure.message);
assert!(!failure.message.contains(CANARY_KEY), "{}", failure.message);
}
// `mind` is the desk's word: renamed to `instance` the payload is no desk request.
let mut renamed = value.clone();
let moved = renamed[*variant].as_object_mut().unwrap().remove("mind").unwrap();
renamed[*variant].as_object_mut().unwrap().insert("instance".into(), moved);
let payload = STANDARD.encode(rmp_serde::to_vec_named(&renamed).unwrap());
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
assert_eq!(decode_desk_request(&message).unwrap_err().code, "payload-not-a-request");
}
// And the other way: the mind's `instance` named `mind` is no persona request.
let put = HuginnMindRequest::PersonaPut {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
state: serde_json::json!({}),
expected_updated_at: None,
};
let mut value = serde_json::to_value(&put).unwrap();
let moved = value["PersonaPut"].as_object_mut().unwrap().remove("instance").unwrap();
value["PersonaPut"].as_object_mut().unwrap().insert("mind".into(), moved);
let message = request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()));
assert_eq!(decode_request(&message).unwrap_err().code, "payload-not-a-request");
}
const CANARY_KEY: &str = "CANARY-9e41-do-not-echo";
/// Two services share the cores and nothing else: a payload under the other service's schema,
/// service id or operation name never reaches a decoder.
#[test]
fn desk_and_mind_payloads_do_not_cross() {
let whoami = encode_request("m-5", &HuginnMindRequest::Whoami, None).unwrap();
let CultNetMessage::OperationRequest { payload: mind_payload, .. } = &whoami else { panic!("not a request") };
for (request, operation) in desk_requests().iter().map(|(request, _)| (request, request.operation())) {
let CultNetMessage::OperationRequest { payload: desk_payload, .. } = encode_desk_request("m-6", request, None).unwrap() else { panic!("not a request") };
// A desk payload under the mind's schema and a mind payload under the desk's.
let under_mind = request_message(DESK_SERVICE_ID, operation, MIND_REQUEST_SCHEMA, desk_payload.clone());
assert_eq!(decode_desk_request(&under_mind).unwrap_err().code, "wrong-payload-schema");
let under_desk = request_message(MIND_SERVICE_ID, "whoami", DESK_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_request(&under_desk).unwrap_err().code, "wrong-payload-schema");
// The mind's service id at the desk decoder, and the desk's at the mind's.
let mind_service = request_message(MIND_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, desk_payload.clone());
assert_eq!(decode_desk_request(&mind_service).unwrap_err().code, "wrong-service");
let desk_service = request_message(DESK_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_request(&desk_service).unwrap_err().code, "wrong-service");
// The right labels around the other service's bytes: not a request.
let wrong_bytes = request_message(DESK_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_desk_request(&wrong_bytes).unwrap_err().code, "payload-not-a-request");
// An operation name that is not the payload's.
let other = if operation == "forks" { "fork" } else { "forks" };
let mismatch = request_message(DESK_SERVICE_ID, other, DESK_REQUEST_SCHEMA, desk_payload);
assert_eq!(decode_desk_request(&mismatch).unwrap_err().code, "operation-mismatch");
}
// The three operations name themselves, on the envelope too.
let named: Vec<String> = desk_requests()
.iter()
.map(|(request, _)| match encode_desk_request("m", request, None).unwrap() {
CultNetMessage::OperationRequest { service_id, operation, payload_schema, .. } => {
assert_eq!((service_id.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, DESK_REQUEST_SCHEMA));
operation
}
_ => panic!("not a request"),
})
.collect();
assert_eq!(named, ["answer", "forks", "fork"]);
}
/// A desk answer is an answer on the desk's response schema; a refusal that stored nothing and
/// any other Mind refusal ride `rejected`; a failure rides the shared failure schema under the
/// desk's service id.
#[test]
fn a_desk_response_round_trips_with_its_status() {
let answered = DeskResponse::Answered {
answer: ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0,
answer_id: "n1".into(),
fork_id: "eureka-state:question:Q1".into(),
outcome: "Counted".into(),
of: None,
retracts: None,
code: None,
options_hash: "0".repeat(64),
choice: Some("A".into()),
note: None,
answered_at: "2026-11-10T12:00:00Z".into(),
received_at: "2026-11-10T12:00:01Z".into(),
claim_in_future: false,
answerer: "user-7".into(),
authority: DeskAuthority::Member,
request_id: "00000000-0000-4000-8000-000000000001".into(),
},
counted_answer_id: Some("n1".into()),
as_of: 9,
};
let not_stored = DeskResponse::NotStored(ForkRefusal { schema_version: RefusalSchema::V0, code: ForkRefusalCode::ForkUnknown, error: "fork_unknown".into() });
for (response, status) in [(answered, "accepted"), (not_stored, "rejected"), (DeskResponse::Refused(refusal()), "rejected")] {
let message = encode_desk_response("m-7", "answer", &response, "huginn-yggdrasil").unwrap();
let CultNetMessage::OperationResponse { service_id, status: sent, payload_schema, .. } = &message else { panic!("not a response") };
assert_eq!((service_id.as_str(), sent.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, status, DESK_RESPONSE_SCHEMA));
assert_eq!(decode_desk_response(&message).unwrap(), ("m-7".into(), Ok(response)));
}
let failure = OperationFailure::new("wrong-service", "the service is not huginn.desk");
let message = encode_desk_failure("m-8", "forks", &failure, "huginn-yggdrasil");
let CultNetMessage::OperationResponse { service_id, status, payload_schema, .. } = &message else { panic!("not a response") };
assert_eq!((service_id.as_str(), status.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, "rejected", FAILURE_SCHEMA));
assert_eq!(decode_desk_response(&message).unwrap(), ("m-8".into(), Err(failure)));
// The mind's response schema is not the desk's.
let mind = encode_response("m-9", "whoami", &HuginnMindResponse::Refused(refusal()), "huginn-yggdrasil").unwrap();
assert!(decode_desk_response(&mind).is_err());
}
/// The largest message either side can build fits the frame the door enforces: 64 deciders of
/// 200 bytes and a 4000-byte note in a request, the widest stored answer in a response.
#[test]
fn a_maximal_desk_message_fits_the_frame() {
let long = |n: usize| "x".repeat(n);
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len();
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::s(&format!("{at:0>200}"))).collect();
let mut sized = vec![];
for (request, _) in desk_requests() {
let widest = match request {
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
mind,
subject: crate::fixtures::s(&long(200)),
authority,
deciders: deciders.clone(),
request: ForkAnswerRequest {
fork_id: long(200),
answer: Some(ForkAnswer { choice: Some(long(200)), note: Some(long(4000)) }),
retracts: Some("n4294967295".into()),
..request
},
},
DeskRequest::Forks { mind, .. } => DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone() },
DeskRequest::Fork { mind, .. } => {
DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) }
}
};
let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
assert_eq!(decode_desk_request(&message).unwrap().1, widest);
sized.push(wire(&message));
}
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}");
assert!(sized[0] > DESK_MAX_REQUEST_BYTES / 4, "the test builds a request that is actually large: {sized:?}");
let answer = ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0,
answer_id: "n4294967295".into(),
fork_id: long(200),
outcome: "Refused".into(),
of: Some("n4294967295".into()),
retracts: Some("n4294967295".into()),
code: None,
options_hash: "f".repeat(64),
choice: Some(long(200)),
note: Some(long(4000)),
answered_at: "2026-11-10T12:00:00.123456789+00:00".into(),
received_at: "2026-11-10T12:00:00.123456789+00:00".into(),
claim_in_future: true,
answerer: long(200),
authority: DeskAuthority::Operator,
request_id: "00000000-0000-4000-8000-000000000001".into(),
};
let response = DeskResponse::Answered { answer, counted_answer_id: Some("n4294967295".into()), as_of: u64::MAX };
let message = encode_desk_response("m-11", "answer", &response, &long(200)).unwrap();
assert!(wire(&message) <= DESK_MAX_RESPONSE_BYTES && wire(&message) > 4000, "{}", wire(&message));
assert_eq!(decode_desk_response(&message).unwrap().1, Ok(response));
}
/// A desk call's refusal rides `huginn.desk_response.v1` whole: code and detail survive the
/// wire, and the published schema declares the variant. One row per refusal a desk call can
/// return, the four the door maps to `NotStored` included, because the response type can
/// carry any of them.
#[test]
fn every_refusal_a_desk_call_can_return_round_trips_losslessly() {
use crate::desk_wire::DESK_RESPONSE_SCHEMA_JSON;
use eureka_pipeline::{PipelineKind, PipelineRefusal};
let refusals = [
MindRefusal::Unavailable { detail: "the store did not answer".into() },
MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() },
MindRefusal::MissingIdentity,
MindRefusal::DeskAnswerOutsideDesk { id: "eureka-state:desk_answer:Q1.n1".into() },
MindRefusal::MissingReference { kind: PipelineKind::Question, id: "eureka-state:question:Q9".into() },
MindRefusal::CampaignMoved { campaign: "eureka-state".into(), to: OTHER.into() },
MindRefusal::DeskRequestInvalid { field: "desk_answer.note".into() },
MindRefusal::DeskRequestIdConflict { request_id: "00000000-0000-4000-8000-000000000001".into() },
MindRefusal::Document(PipelineRefusal::FieldBound { field: "note".into(), limit: 4000, actual: 4001 }),
MindRefusal::Document(PipelineRefusal::InvalidFormat { field: "request_id".into(), value: "x".into() }),
MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: PipelineKind::DeskAnswer, key: "k".into(), expected: "e".into() }),
MindRefusal::Document(PipelineRefusal::DuplicateRepo { field: "repos".into(), repo: "GameCult/Huginn".into() }),
MindRefusal::Document(PipelineRefusal::ForeignStore { r#type: "other".into() }),
];
let schema: serde_json::Value = serde_json::from_str(DESK_RESPONSE_SCHEMA_JSON).unwrap();
assert!(schema["$defs"]["MindRefusal"].is_object() && schema["$defs"]["PipelineRefusal"].is_object());
let declared = serde_json::to_string(&schema).unwrap();
for refusal in refusals {
let response = DeskResponse::Refused(refusal.clone());
let message = encode_desk_response("m-12", "answer", &response, "huginn-yggdrasil").unwrap();
let (_, decoded) = decode_desk_response(&message).unwrap();
assert_eq!(decoded, Ok(DeskResponse::Refused(refusal.clone())), "{refusal:?}");
let value = serde_json::to_value(&refusal).unwrap();
let variant = value.as_str().map(str::to_string).or_else(|| value.as_object().and_then(|object| object.keys().next().cloned())).unwrap();
assert!(declared.contains(&format!("\"{variant}\"")), "the published schema declares {variant}");
// Each detail field is declared too, so a validator can hold the payload to the schema.
if let Some(object) = value.get(&variant).and_then(|inner| inner.as_object()) {
for field in object.keys() {
assert!(declared.contains(&format!("\"{field}\"")), "{variant}.{field} is declared");
}
}
}
}
}

View file

@ -581,4 +581,27 @@ mod tests {
assert!(request.contains(&format!("\"$ref\": \"{selection}\"")));
assert!(!request.contains("\"cursor\""), "no second copy of the selection's shape");
}
/// The desk's two published files are the derivation of its two types, byte for byte, and
/// the catalog index lists both as wire messages beside the mind's.
#[test]
fn published_desk_schemas_match_derivation() {
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA, DESK_RESPONSE_SCHEMA_JSON, DeskRequest, DeskResponse};
let request = serde_json::to_string_pretty(&schemars::schema_for!(DeskRequest)).unwrap();
let response = serde_json::to_string_pretty(&schemars::schema_for!(DeskResponse)).unwrap();
assert_eq!(DESK_REQUEST_SCHEMA_JSON.replace("\r\n", "\n"), request);
assert_eq!(DESK_RESPONSE_SCHEMA_JSON.replace("\r\n", "\n"), response);
// The deciders bound is in the published schema, not only in the decoder.
assert!(request.contains("\"maxItems\": 64") && request.contains("\"uniqueItems\": true") && request.contains("\"minItems\": 1"), "deciders bound");
let index: serde_json::Value = serde_json::from_str(include_str!("../../../schemas/cultnet/index.json")).unwrap();
for (id, title) in [(DESK_REQUEST_SCHEMA, "Huginn desk request"), (DESK_RESPONSE_SCHEMA, "Huginn desk response")] {
let rows: Vec<_> = index["schemas"].as_array().unwrap().iter().filter(|row| row["schemaId"] == id).collect();
assert_eq!(rows.len(), 1, "{id}");
let row = rows[0];
assert_eq!(
(row["kind"].as_str(), row["schemaVersion"].as_str(), row["title"].as_str(), row["path"].as_str(), row["wireContracts"][0].as_str()),
(Some("wire_message"), Some(id), Some(title), Some(format!("{id}.schema.json").as_str()), Some("cultnet.schema.v0"))
);
}
}
}

View file

@ -0,0 +1,206 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskRequest",
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside the set refuses\nthe whole payload. The reads answer in follow-up desk-wire-reads.",
"oneOf": [
{
"type": "object",
"properties": {
"Answer": {
"type": "object",
"properties": {
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"mind": {
"$ref": "#/$defs/Slug"
},
"request": {
"$ref": "#/$defs/ForkAnswerRequest"
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"authority",
"deciders",
"request"
]
}
},
"additionalProperties": false,
"required": [
"Answer"
]
},
{
"type": "object",
"properties": {
"Forks": {
"type": "object",
"properties": {
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"mind": {
"$ref": "#/$defs/Slug"
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"deciders"
]
}
},
"additionalProperties": false,
"required": [
"Forks"
]
},
{
"type": "object",
"properties": {
"Fork": {
"type": "object",
"properties": {
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"fork_id": {
"type": "string"
},
"mind": {
"$ref": "#/$defs/Slug"
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"deciders",
"fork_id"
]
}
},
"additionalProperties": false,
"required": [
"Fork"
]
}
],
"$defs": {
"DeskAuthority": {
"type": "string",
"enum": [
"Operator",
"Steward",
"Member"
]
},
"ForkAnswerRequest": {
"description": "`gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the\nshape step of `Mind::answer_desk` judges it. The subject and the authority are the\ndoor's context and are no field here.",
"type": "object",
"properties": {
"answer": {
"description": "The person's words: a choice, a note, or both.",
"type": "object",
"properties": {
"choice": {
"type": "string"
},
"note": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"choice",
"note"
]
},
"answered_at": {
"type": "string"
},
"fork_id": {
"type": "string"
},
"options_hash": {
"type": "string"
},
"request_id": {
"type": "string"
},
"retracted": {
"type": "boolean"
},
"retracts": {
"type": "string"
},
"schemaVersion": {
"$ref": "#/$defs/RequestSchema"
}
},
"additionalProperties": false,
"required": [
"schemaVersion",
"fork_id",
"options_hash",
"answer",
"retracted",
"retracts",
"answered_at",
"request_id"
]
},
"RequestSchema": {
"description": "The request document's schema id; any other value fails to decode.",
"type": "string",
"enum": [
"gamecult.fork.answer_request.v0"
]
},
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"type": "string",
"maxLength": 200
},
"Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.",
"type": "string",
"maxLength": 64
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -262,6 +262,26 @@
"schemaVersion": "huginn.mind_response.v1",
"title": "Huginn mind response",
"path": "huginn.mind_response.v1.schema.json"
},
{
"schemaId": "huginn.desk_request.v1",
"kind": "wire_message",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "huginn.desk_request.v1",
"title": "Huginn desk request",
"path": "huginn.desk_request.v1.schema.json"
},
{
"schemaId": "huginn.desk_response.v1",
"kind": "wire_message",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "huginn.desk_response.v1",
"title": "Huginn desk response",
"path": "huginn.desk_response.v1.schema.json"
}
]
}