From ab1b150d2f950d0770ad3df4e6871ce18c03f0dd Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 20:28:16 +0000 Subject: [PATCH 1/4] Desk wire envelope: failing tests first (deciders 64/65, wire names, no crossing, frame, schemas) The desk request, response, envelope instantiations and constants do not exist yet; these tests name them and do not compile. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire.rs | 92 ++++++++++- crates/huginn-mind/src/envelope.rs | 235 ++++++++++++++++++++++++++++ crates/huginn-mind/src/wire.rs | 23 +++ 3 files changed, 349 insertions(+), 1 deletion(-) diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 2809998..64d4377 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -1380,7 +1380,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 +1390,94 @@ 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 { + (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 { + 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 a_duplicate_decider_is_refused_naming_deciders_and_an_empty_list_decodes() { + let mut mind = desk(); + let hash = hash_of(&mind, FORK); + let mut twice = list_of(3); + twice.push(s("d1")); + for sent in desk_requests(INSTANCE, &twice, &hash) { + let failure = through_the_door(&sent).unwrap_err(); + assert_eq!(failure.code, "payload-not-a-request"); + assert!(failure.message.contains("deciders") && !failure.message.contains("d1"), "{}", failure.message); + } + // An empty list is a list: it decodes, and the Mind's showability gate is what says no. + let before = head(&mind); + let [answer, forks, fork] = desk_requests(INSTANCE, &[], &hash); + assert!(through_the_door(&forks).is_ok() && through_the_door(&fork).is_ok()); + let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&answer).unwrap() else { panic!("not an answer") }; + assert!(deciders.is_empty()); + let refused = not_stored(mind.desk_answer(&deciders, subject, authority, request, now()).0); + assert_eq!(refused.code, ForkRefusalCode::ForkUnknown); + assert_eq!(head(&mind), 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"); + } } diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index 4ac9544..984675f 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -324,4 +324,239 @@ 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 = 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 = 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) > DESK_MAX_RESPONSE_BYTES / 8, "{}", wire(&message)); + assert_eq!(decode_desk_response(&message).unwrap().1, Ok(response)); + } } diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index a64948d..3a5124a 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -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"), "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")) + ); + } + } } From a19fc5b494fc42001b6cf59dc76b55dd55523c4c Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 20:31:21 +0000 Subject: [PATCH 2/4] Desk wire envelope: DeskRequest with Libby's wire names, deciders per call (max 64), generic envelope cores, desk schemas envelope.rs cores are generic over service id, schema and payload; the mind's five functions and the desk's five instantiate them. desk_answer's attribution parameter is authority. huginn.desk_request.v1 and huginn.desk_response.v1 are derived and pinned. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire.rs | 138 +- crates/huginn-mind/src/envelope.rs | 145 +- .../huginn.desk_request.v1.schema.json | 203 +++ .../huginn.desk_response.v1.schema.json | 1512 +++++++++++++++++ schemas/cultnet/index.json | 20 + 5 files changed, 1972 insertions(+), 46 deletions(-) create mode 100644 schemas/cultnet/huginn.desk_request.v1.schema.json create mode 100644 schemas/cultnet/huginn.desk_response.v1.schema.json diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 64d4377..7863b5d 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -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,126 @@ 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: at most `DESK_DECIDERS_MAX`, no subject 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, D::Error> { + struct List; + impl<'de> serde::de::Visitor<'de> for List { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a list of distinct deciders") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut list = Vec::new(); + while let Some(subject) = seq.next_element::()? { + 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); + } + Ok(list) + } + } + deserializer.deserialize_seq(List) +} + +fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "array", + "items": generator.subschema_for::(), + "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, which the Mind never stores. 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, + request: ForkAnswerRequest, + }, + Forks { + mind: Slug, + subject: Short, + #[serde(deserialize_with = "deciders")] + #[schemars(schema_with = "deciders_schema")] + deciders: Vec, + }, + Fork { + mind: Slug, + subject: Short, + #[serde(deserialize_with = "deciders")] + #[schemars(schema_with = "deciders_schema")] + deciders: Vec, + 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 +444,17 @@ impl Mind { /// 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, ) -> (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 +464,7 @@ impl Mind { &mut self, deciders: &[Short], subject: Short, - attribution: DeskAuthority, + authority: DeskAuthority, request: &ForkAnswerRequest, now: DateTime, ) -> Result<(DeskResponse, bool), MindRefusal> { @@ -361,7 +481,7 @@ impl Mind { 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), diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index 984675f..2bf520d 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -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(value: &T) -> Result { 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( message_id: &str, - request: &HuginnMindRequest, + service_id: &str, + payload_schema: &str, + operation: &str, + request: &T, source_runtime_id: Option, ) -> Result { 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( + 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::(&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 { +fn read_payload(bytes: &[u8]) -> Result { 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 { }) } -/// One answer, whose status the response derives. -pub fn encode_response( +/// One answer, with the status its caller derived. +fn encode_operation_response( message_id: &str, + service_id: &str, + payload_schema: &str, operation: &str, - response: &HuginnMindResponse, + status: &str, + response: &T, source_runtime_id: &str, ) -> Result { 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)> { +/// 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( + message: &CultNetMessage, + service_id: &str, + response_schema: &str, +) -> Result<(String, Result)> { 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::(&bytes)?), - FAILURE_SCHEMA => Err(rmp_serde::from_slice::(&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::(&bytes)?) + } else if payload_schema == FAILURE_SCHEMA { + Err(rmp_serde::from_slice::(&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, +) -> Result { + 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 { + 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)> { + 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) -> Result { + 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 { + 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)> { + decode_operation_response(message, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA) +} + #[cfg(test)] mod tests { use super::*; @@ -556,7 +627,7 @@ mod tests { }; 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) > DESK_MAX_RESPONSE_BYTES / 8, "{}", wire(&message)); + assert!(wire(&message) <= DESK_MAX_RESPONSE_BYTES && wire(&message) > 4000, "{}", wire(&message)); assert_eq!(decode_desk_response(&message).unwrap().1, Ok(response)); } } diff --git a/schemas/cultnet/huginn.desk_request.v1.schema.json b/schemas/cultnet/huginn.desk_request.v1.schema.json new file mode 100644 index 0000000..53f3598 --- /dev/null +++ b/schemas/cultnet/huginn.desk_request.v1.schema.json @@ -0,0 +1,203 @@ +{ + "$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, which the Mind never stores. 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, + "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, + "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, + "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 + } + } +} \ No newline at end of file diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json new file mode 100644 index 0000000..074acc0 --- /dev/null +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -0,0 +1,1512 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "DeskResponse", + "description": "What the door answers one desk request with. `Answered` is a stored answer, refused ones\nincluded (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.\n`NotStored` is a request that stored nothing. `Refused` is any other Mind refusal; an\n`Unavailable` is Bifrost's to turn into its 503, and Huginn writes no document for it.", + "oneOf": [ + { + "type": "object", + "properties": { + "Answered": { + "type": "object", + "properties": { + "answer": { + "$ref": "#/$defs/ForkDeskAnswer" + }, + "as_of": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "counted_answer_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "answer", + "as_of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Answered" + ] + }, + { + "type": "object", + "properties": { + "NotStored": { + "$ref": "#/$defs/ForkRefusal" + } + }, + "additionalProperties": false, + "required": [ + "NotStored" + ] + }, + { + "type": "object", + "properties": { + "Refused": { + "$ref": "#/$defs/MindRefusal" + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + } + ], + "$defs": { + "AnswerSchema": { + "description": "The desk answer document's schema id.", + "type": "string", + "enum": [ + "gamecult.desk_answer.v0" + ] + }, + "DeskAuthority": { + "type": "string", + "enum": [ + "Operator", + "Steward", + "Member" + ] + }, + "ForkDeskAnswer": { + "description": "`gamecult.desk_answer.v0`: one stored answer as the desk shows it, her field order,\n`request_id` last. Every value is a stored fact; `answer_id`, `of` and `retracts` are the\nrecord keys in her `n` spelling.", + "type": "object", + "properties": { + "answer_id": { + "type": "string" + }, + "answered_at": { + "type": "string" + }, + "answerer": { + "type": "string" + }, + "authority": { + "$ref": "#/$defs/DeskAuthority" + }, + "choice": { + "type": "string" + }, + "claim_in_future": { + "type": "boolean" + }, + "code": { + "description": "The six codes a refused answer is stored with, as her schema spells them.", + "type": "string", + "enum": [ + "stale_fork", + "not_a_decider", + "fork_closed", + "choice_invalid", + "bad_retraction", + "already_retracted" + ] + }, + "fork_id": { + "type": "string" + }, + "note": { + "type": "string" + }, + "of": { + "type": "string" + }, + "options_hash": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "received_at": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "retracts": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/AnswerSchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "answer_id", + "fork_id", + "outcome", + "of", + "retracts", + "code", + "options_hash", + "choice", + "note", + "answered_at", + "received_at", + "claim_in_future", + "answerer", + "authority", + "request_id" + ] + }, + "ForkRefusal": { + "description": "`gamecult.fork.refusal.v0`. `error` is a fixed sentence, never an echo of what was sent;\nfor `body_invalid` it names the field.", + "type": "object", + "properties": { + "code": { + "$ref": "#/$defs/ForkRefusalCode" + }, + "error": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/RefusalSchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "code", + "error" + ] + }, + "ForkRefusalCode": { + "description": "The three requests Huginn refuses without storing, by code.", + "type": "string", + "enum": [ + "fork_unknown", + "body_invalid", + "request_id_conflict" + ] + }, + "MindRefusal": { + "description": "Every way a mind refuses. `Document` wraps the leaf's four (bounds,\nformats, key identity, foreign type); the rest are the organ's: store\nidentity, epoch, ownership, batch shape, references, and every cross-field\nand cross-document rule of admission.\n\nThe two sequenced kinds refuse in the same pair of shapes: an\n`OutOfSequence` when the writer's `sequence` is not the previous plus one,\nand `AlreadyResolved`/`AlreadyStewarded` when a record of that scope is\nstill in force.", + "oneOf": [ + { + "type": "string", + "enum": [ + "MissingIdentity", + "EmptySupersession", + "FindingWithoutRange", + "FindingWithoutEvidence" + ] + }, + { + "type": "object", + "properties": { + "Document": { + "$ref": "#/$defs/PipelineRefusal" + } + }, + "additionalProperties": false, + "required": [ + "Document" + ] + }, + { + "type": "object", + "properties": { + "ForeignInstance": { + "type": "object", + "properties": { + "declared": { + "type": "string" + }, + "mind": { + "type": "string" + } + }, + "required": [ + "declared", + "mind" + ] + } + }, + "additionalProperties": false, + "required": [ + "ForeignInstance" + ] + }, + { + "type": "object", + "properties": { + "ForeignEpoch": { + "type": "object", + "properties": { + "expected": { + "type": "string" + }, + "found": { + "type": "string" + } + }, + "required": [ + "found", + "expected" + ] + } + }, + "additionalProperties": false, + "required": [ + "ForeignEpoch" + ] + }, + { + "type": "object", + "properties": { + "ForeignStore": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "additionalProperties": false, + "required": [ + "ForeignStore" + ] + }, + { + "type": "object", + "properties": { + "MindAlreadyOwned": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + "additionalProperties": false, + "required": [ + "MindAlreadyOwned" + ] + }, + { + "type": "object", + "properties": { + "BatchSize": { + "type": "object", + "properties": { + "actual": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "actual" + ] + } + }, + "additionalProperties": false, + "required": [ + "BatchSize" + ] + }, + { + "type": "object", + "properties": { + "IdentityCollision": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/PipelineKind" + } + }, + "required": [ + "kind", + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "IdentityCollision" + ] + }, + { + "type": "object", + "properties": { + "MissingReference": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/PipelineKind" + } + }, + "required": [ + "kind", + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "MissingReference" + ] + }, + { + "description": "`closed_by` is the resolution in force: to correct the closure, withdraw it (a resolution of `closed_by`), then resolve `subject` again at the next sequence.", + "type": "object", + "properties": { + "AlreadyResolved": { + "type": "object", + "properties": { + "closed_by": { + "type": "string" + }, + "subject": { + "type": "string" + } + }, + "required": [ + "subject", + "closed_by" + ] + } + }, + "additionalProperties": false, + "required": [ + "AlreadyResolved" + ] + }, + { + "type": "object", + "properties": { + "ResolutionOutOfSequence": { + "type": "object", + "properties": { + "actual": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "expected": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "subject": { + "type": "string" + } + }, + "required": [ + "subject", + "expected", + "actual" + ] + } + }, + "additionalProperties": false, + "required": [ + "ResolutionOutOfSequence" + ] + }, + { + "type": "object", + "properties": { + "AlreadyStewarded": { + "type": "object", + "properties": { + "repo": { + "type": "string" + } + }, + "required": [ + "repo" + ] + } + }, + "additionalProperties": false, + "required": [ + "AlreadyStewarded" + ] + }, + { + "type": "object", + "properties": { + "StewardshipOutOfSequence": { + "type": "object", + "properties": { + "actual": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "expected": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "repo": { + "type": "string" + } + }, + "required": [ + "repo", + "expected", + "actual" + ] + } + }, + "additionalProperties": false, + "required": [ + "StewardshipOutOfSequence" + ] + }, + { + "type": "object", + "properties": { + "IncompatibleResolution": { + "type": "object", + "properties": { + "outcome": { + "type": "string" + }, + "subject_kind": { + "$ref": "#/$defs/PipelineKind" + } + }, + "required": [ + "subject_kind", + "outcome" + ] + } + }, + "additionalProperties": false, + "required": [ + "IncompatibleResolution" + ] + }, + { + "type": "object", + "properties": { + "WouldReinstateOverLater": { + "type": "object", + "properties": { + "later": { + "type": "string" + }, + "subject": { + "type": "string" + } + }, + "required": [ + "subject", + "later" + ] + } + }, + "additionalProperties": false, + "required": [ + "WouldReinstateOverLater" + ] + }, + { + "type": "object", + "properties": { + "CitesResolvedDocument": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/PipelineKind" + } + }, + "required": [ + "kind", + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "CitesResolvedDocument" + ] + }, + { + "type": "object", + "properties": { + "UnknownSupersessor": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownSupersessor" + ] + }, + { + "description": "A consolidation and the supersession of the memory it consumes come together: `memory` consolidates `consumed`, or is named as superseding it, without the other half in the batch.", + "type": "object", + "properties": { + "UnpairedConsolidation": { + "type": "object", + "properties": { + "consumed": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "required": [ + "memory", + "consumed" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnpairedConsolidation" + ] + }, + { + "type": "object", + "properties": { + "RevisionWithoutSupersession": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/PipelineKind" + }, + "revision": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "kind", + "revision" + ] + } + }, + "additionalProperties": false, + "required": [ + "RevisionWithoutSupersession" + ] + }, + { + "type": "object", + "properties": { + "DuplicateLabel": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "field", + "label" + ] + } + }, + "additionalProperties": false, + "required": [ + "DuplicateLabel" + ] + }, + { + "type": "object", + "properties": { + "InvalidOptions": { + "type": "object", + "properties": { + "question": { + "type": "string" + } + }, + "required": [ + "question" + ] + } + }, + "additionalProperties": false, + "required": [ + "InvalidOptions" + ] + }, + { + "type": "object", + "properties": { + "InvalidChoice": { + "type": "object", + "properties": { + "choice": { + "type": "string" + }, + "ruling": { + "type": "string" + } + }, + "required": [ + "ruling", + "choice" + ] + } + }, + "additionalProperties": false, + "required": [ + "InvalidChoice" + ] + }, + { + "type": "object", + "properties": { + "QuoteWithoutOperator": { + "type": "object", + "properties": { + "ruling": { + "type": "string" + } + }, + "required": [ + "ruling" + ] + } + }, + "additionalProperties": false, + "required": [ + "QuoteWithoutOperator" + ] + }, + { + "description": "A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key.", + "type": "object", + "properties": { + "DeskAnswerOutsideDesk": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskAnswerOutsideDesk" + ] + }, + { + "description": "A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id).", + "type": "object", + "properties": { + "DeskRequestInvalid": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + }, + "required": [ + "field" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskRequestInvalid" + ] + }, + { + "description": "The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored.", + "type": "object", + "properties": { + "DeskRequestIdConflict": { + "type": "object", + "properties": { + "request_id": { + "type": "string" + } + }, + "required": [ + "request_id" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskRequestIdConflict" + ] + }, + { + "description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.", + "type": "object", + "properties": { + "EmptyRepos": { + "type": "object", + "properties": { + "campaign": { + "type": "string" + } + }, + "required": [ + "campaign" + ] + } + }, + "additionalProperties": false, + "required": [ + "EmptyRepos" + ] + }, + { + "type": "object", + "properties": { + "RepoNotInCampaign": { + "type": "object", + "properties": { + "repo": { + "type": "string" + } + }, + "required": [ + "repo" + ] + } + }, + "additionalProperties": false, + "required": [ + "RepoNotInCampaign" + ] + }, + { + "type": "object", + "properties": { + "UnknownDependency": { + "type": "object", + "properties": { + "cut": { + "type": "string" + } + }, + "required": [ + "cut" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownDependency" + ] + }, + { + "type": "object", + "properties": { + "CutReportWithoutSpec": { + "type": "object", + "properties": { + "report": { + "type": "string" + } + }, + "required": [ + "report" + ] + } + }, + "additionalProperties": false, + "required": [ + "CutReportWithoutSpec" + ] + }, + { + "type": "object", + "properties": { + "SpecMismatch": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + }, + "required": [ + "field" + ] + } + }, + "additionalProperties": false, + "required": [ + "SpecMismatch" + ] + }, + { + "type": "object", + "properties": { + "RangeOutsideCommits": { + "type": "object", + "properties": { + "head": { + "type": "string" + } + }, + "required": [ + "head" + ] + } + }, + "additionalProperties": false, + "required": [ + "RangeOutsideCommits" + ] + }, + { + "type": "object", + "properties": { + "FalsifiedClaimWithoutConfirmedFinding": { + "type": "object", + "properties": { + "claim": { + "type": "string" + } + }, + "required": [ + "claim" + ] + } + }, + "additionalProperties": false, + "required": [ + "FalsifiedClaimWithoutConfirmedFinding" + ] + }, + { + "type": "object", + "properties": { + "UnprovenClaimWithConfirmedFinding": { + "type": "object", + "properties": { + "claim": { + "type": "string" + } + }, + "required": [ + "claim" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnprovenClaimWithConfirmedFinding" + ] + }, + { + "type": "object", + "properties": { + "PromiseWithoutVerdict": { + "type": "object", + "properties": { + "promise": { + "type": "string" + } + }, + "required": [ + "promise" + ] + } + }, + "additionalProperties": false, + "required": [ + "PromiseWithoutVerdict" + ] + }, + { + "type": "object", + "properties": { + "UnknownMutationLabel": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownMutationLabel" + ] + }, + { + "type": "object", + "properties": { + "UnknownInvariant": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownInvariant" + ] + }, + { + "type": "object", + "properties": { + "NotStewarded": { + "type": "object", + "properties": { + "repo": { + "type": "string" + } + }, + "required": [ + "repo" + ] + } + }, + "additionalProperties": false, + "required": [ + "NotStewarded" + ] + }, + { + "description": "`campaign` moved to mind `to`: nothing is written under its root here any more, whoever asks.", + "type": "object", + "properties": { + "CampaignMoved": { + "type": "object", + "properties": { + "campaign": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "campaign", + "to" + ] + } + }, + "additionalProperties": false, + "required": [ + "CampaignMoved" + ] + }, + { + "description": "A campaign moves to another mind, never to the one that gives it up.", + "type": "string", + "const": "MoveToSelf" + }, + { + "description": "A Moved resolution is final; withdrawing it would leave two minds keeping the campaign.", + "type": "string", + "const": "MoveIsFinal" + }, + { + "description": "An import names this mind as its source.", + "type": "string", + "const": "ImportFromSelf" + }, + { + "description": "An import carries a document of `root`, which is no campaign this mind holds or the batch carries, or under which this mind holds a native write that is not a follow-up or idea admitted after the import began, or which cites or resolves one of its own native documents.", + "type": "object", + "properties": { + "ImportOutsideCampaign": { + "type": "object", + "properties": { + "root": { + "type": "string" + } + }, + "required": [ + "root" + ] + } + }, + "additionalProperties": false, + "required": [ + "ImportOutsideCampaign" + ] + }, + { + "description": "A move names `root`, this mind's own instance: an instance's records are not a campaign's to freeze.", + "type": "object", + "properties": { + "MoveOfInstanceRoot": { + "type": "object", + "properties": { + "root": { + "type": "string" + } + }, + "required": [ + "root" + ] + } + }, + "additionalProperties": false, + "required": [ + "MoveOfInstanceRoot" + ] + }, + { + "description": "An import lists `id` as external, but this mind (or the batch) holds it.", + "type": "object", + "properties": { + "ExternalPresent": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "ExternalPresent" + ] + }, + { + "description": "The persona document fails the published schema: `path` is the\ndocument location (a key the schema does not declare is `*`) and\n`message` the schema keyword that failed. Never the offending value.", + "type": "object", + "properties": { + "PersonaInvalid": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaInvalid" + ] + }, + { + "description": "The document's `personaId` is not this mind's instance, which is\n`instance`.", + "type": "object", + "properties": { + "PersonaForeign": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaForeign" + ] + }, + { + "description": "The put's `expected_updated_at` is not the stored document's\n`updatedAt`, which is `stored` (none when no persona is stored).", + "type": "object", + "properties": { + "PersonaStale": { + "type": "object", + "properties": { + "stored": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false, + "required": [ + "PersonaStale" + ] + }, + { + "description": "The document's `provenance.authority` is not `canonical`: a projection\nor an import is not the mind's persona.", + "type": "string", + "const": "PersonaNotCanonical" + }, + { + "description": "A run claims work another run in force already holds: `item` is the\nclaimed document's id and `run` the id of the run that holds it.", + "type": "object", + "properties": { + "AlreadyClaimed": { + "type": "object", + "properties": { + "item": { + "type": "string" + }, + "run": { + "$ref": "#/$defs/RunId" + } + }, + "required": [ + "item", + "run" + ] + } + }, + "additionalProperties": false, + "required": [ + "AlreadyClaimed" + ] + }, + { + "description": "A run of hers opens, or is reinstated, while another in-force run of\nhers of the same instance and turn is live: `run` is the holder's id\n(ruling one-live-self-run).", + "type": "object", + "properties": { + "AlreadyLive": { + "type": "object", + "properties": { + "run": { + "$ref": "#/$defs/RunId" + } + }, + "required": [ + "run" + ] + } + }, + "additionalProperties": false, + "required": [ + "AlreadyLive" + ] + }, + { + "description": "The one refusal a mind never raises: the answer exceeds the largest\nbody the organ will deliver by any plane, one send or a deferred body.\nIt lives here because a refusal rides the response schema and there is\none of those, so a client reads it the way it reads every other refusal\nrather than parsing a second vocabulary. `bytes` is the encoded answer\n(the payload, not its envelope) and `limit` is the largest body the\ndaemon defers, both so a caller can narrow its own request; nothing is\ntruncated or paginated on its behalf. The daemon owns it and its\ndeferral bound decides it; no rule of the mind is involved.", + "type": "object", + "properties": { + "ResponseTooLarge": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "limit": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "bytes", + "limit" + ] + } + }, + "additionalProperties": false, + "required": [ + "ResponseTooLarge" + ] + }, + { + "description": "A selection the substrate or the organ's value door refused: `field`\nis the selection field the client sent, `value` the offending part.", + "type": "object", + "properties": { + "SelectionInvalid": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "field", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "SelectionInvalid" + ] + }, + { + "description": "A cursor that does not decode, does not belong to this selection or\nwas not minted by this process, or names a snapshot this mind has not\nreached.", + "type": "object", + "properties": { + "CursorInvalid": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "CursorInvalid" + ] + }, + { + "type": "object", + "properties": { + "Unavailable": { + "type": "object", + "properties": { + "detail": { + "type": "string" + } + }, + "required": [ + "detail" + ] + } + }, + "additionalProperties": false, + "required": [ + "Unavailable" + ] + } + ] + }, + "PipelineKind": { + "type": "string", + "enum": [ + "Campaign", + "Target", + "Question", + "Ruling", + "CutSpec", + "CutReport", + "Verdict", + "Finding", + "FollowUp", + "Resolution", + "Instance", + "Stewardship", + "HandOff", + "Run", + "Pulse", + "Memory", + "Research", + "Idea", + "Reply", + "Thought", + "DeskAnswer" + ] + }, + "PipelineRefusal": { + "description": "Typed refusals of the pipeline documents: bounds, formats and key identity\n(D2's document half), and `ForeignStore`, raised by `decode` for an\nenvelope of any other type. This is every refusal the leaf decides; a\nstore's identity and its schema epoch are not decided here, since this\ncrate owns no store, and belong to the organ that will admit these\ndocuments.\n\nSerialised externally tagged, serde's default for an enum: each variant is\na one-key map from the variant's name to its named fields, so `field` and\n`value` are on the wire under their own names and a reader of a refusal\ncarried over a wire reads the same parts a local caller matches on. No\nattribute buys that, which is why there is none: an internal or adjacent\ntag would move the fields under a content key without making any of them\nmore visible.", + "oneOf": [ + { + "type": "object", + "properties": { + "FieldBound": { + "type": "object", + "properties": { + "actual": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "field": { + "type": "string" + }, + "limit": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "field", + "limit", + "actual" + ] + } + }, + "additionalProperties": false, + "required": [ + "FieldBound" + ] + }, + { + "type": "object", + "properties": { + "InvalidFormat": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "field", + "value" + ] + } + }, + "additionalProperties": false, + "required": [ + "InvalidFormat" + ] + }, + { + "type": "object", + "properties": { + "InvalidIdentity": { + "type": "object", + "properties": { + "expected": { + "type": "string" + }, + "key": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/PipelineKind" + } + }, + "required": [ + "kind", + "key", + "expected" + ] + } + }, + "additionalProperties": false, + "required": [ + "InvalidIdentity" + ] + }, + { + "type": "object", + "properties": { + "DuplicateRepo": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "repo": { + "type": "string" + } + }, + "required": [ + "field", + "repo" + ] + } + }, + "additionalProperties": false, + "required": [ + "DuplicateRepo" + ] + }, + { + "type": "object", + "properties": { + "ForeignStore": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "additionalProperties": false, + "required": [ + "ForeignStore" + ] + } + ] + }, + "RefusalSchema": { + "description": "The refusal document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.refusal.v0" + ] + }, + "RunId": { + "description": "The id of a run: a full pipeline id of kind Run. A refusal that names a\nholder carries one, so a reader gets a run's id from the wire only if it\nparses as one; a peer cannot fill it with any other string. The only ways\nin are `TryFrom`, which every deserialized value passes, and the\nadmission rules, which name a run the mind holds. The error is a fixed\nmessage: it never echoes the offered value.", + "type": "string" + } + } +} \ No newline at end of file diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index 30f93b2..d2a7fcc 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -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" } ] } From 0e590f36383c25aca410932d9082b24a9934a4ea Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 20:43:31 +0000 Subject: [PATCH 3/4] Desk wire envelope delta: failing tests first (empty deciders refused at decode, minItems 1, refusal round trip) Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire.rs | 30 +++++++++++--------- crates/huginn-mind/src/envelope.rs | 43 +++++++++++++++++++++++++++++ crates/huginn-mind/src/wire.rs | 2 +- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 7863b5d..35382f4 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -1561,25 +1561,29 @@ mod tests { } #[test] - fn a_duplicate_decider_is_refused_naming_deciders_and_an_empty_list_decodes() { + 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 sent in desk_requests(INSTANCE, &twice, &hash) { - let failure = through_the_door(&sent).unwrap_err(); - assert_eq!(failure.code, "payload-not-a-request"); - assert!(failure.message.contains("deciders") && !failure.message.contains("d1"), "{}", failure.message); + 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); + } } - // An empty list is a list: it decodes, and the Mind's showability gate is what says no. - let before = head(&mind); - let [answer, forks, fork] = desk_requests(INSTANCE, &[], &hash); - assert!(through_the_door(&forks).is_ok() && through_the_door(&fork).is_ok()); - let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&answer).unwrap() else { panic!("not an answer") }; - assert!(deciders.is_empty()); - let refused = not_stored(mind.desk_answer(&deciders, subject, authority, request, now()).0); + // 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), before); + assert_eq!((head(&mind), stored_count(&mind, FORK)), before); } #[test] diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index 2bf520d..a2e9e21 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -630,4 +630,47 @@ mod tests { 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"); + } + } + } + } } diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index 3a5124a..8f7b10a 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -592,7 +592,7 @@ mod tests { 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"), "deciders bound"); + 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(); From 14a914a6341df6b61ecc44b30d2b11cd212efcc3 Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 20:44:11 +0000 Subject: [PATCH 4/4] Desk wire envelope delta: decode refuses an empty decider list (minItems 1) Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire.rs | 8 ++++++-- schemas/cultnet/huginn.desk_request.v1.schema.json | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 35382f4..028f424 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -207,7 +207,7 @@ 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: at most `DESK_DECIDERS_MAX`, no subject twice. A +/// 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, D::Error> { struct List; @@ -227,6 +227,9 @@ fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result schemars::Schem schemars::json_schema!({ "type": "array", "items": generator.subschema_for::(), + "minItems": 1, "maxItems": DESK_DECIDERS_MAX, "uniqueItems": true }) @@ -245,7 +249,7 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem /// 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, which the Mind never stores. Closed: a key outside the set refuses +/// `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)] diff --git a/schemas/cultnet/huginn.desk_request.v1.schema.json b/schemas/cultnet/huginn.desk_request.v1.schema.json index 53f3598..caaa6a6 100644 --- a/schemas/cultnet/huginn.desk_request.v1.schema.json +++ b/schemas/cultnet/huginn.desk_request.v1.schema.json @@ -1,7 +1,7 @@ { "$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, which the Mind never stores. Closed: a key outside the set refuses\nthe whole payload. The reads answer in follow-up desk-wire-reads.", + "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", @@ -18,6 +18,7 @@ "$ref": "#/$defs/Short" }, "maxItems": 64, + "minItems": 1, "uniqueItems": true }, "mind": { @@ -57,6 +58,7 @@ "$ref": "#/$defs/Short" }, "maxItems": 64, + "minItems": 1, "uniqueItems": true }, "mind": { @@ -91,6 +93,7 @@ "$ref": "#/$defs/Short" }, "maxItems": 64, + "minItems": 1, "uniqueItems": true }, "fork_id": {