diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 668471a..f66a9e5 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -119,9 +119,6 @@ pub fn runtime_id(instance: &Slug) -> String { format!("huginn-{}", instance.0) } -/// What a desk read is told until follow-up desk-door-reads builds the reads and deletes this. -const DESK_READS_UNBUILT: &str = "desk reads not built"; - /// One mind and whatever indexes what lands in it. pub struct Daemon> { mind: Mind, @@ -238,7 +235,8 @@ impl> Daemon { /// One desk request, answered by the Mind method it names. The Mind is the instance's own /// (`mind` is checked against it); `deciders` is this call's list, handed to the Mind and /// dropped. A desk answer is not indexed (`index_text` gives it no text), so nothing here tells the index. - /// The reads wait on follow-up desk-door-reads and are refused until they exist. + /// The reads answer with the whole record and nothing here gates them (ruling + /// `desk-read-visibility-whole-record`): they are handed `deciders` and not `subject`. pub fn handle_desk(&mut self, request: DeskRequest, now: DateTime) -> DeskResponse { if let Err(refusal) = self.mind.require_instance(request.mind()) { return DeskResponse::Refused(refusal); @@ -247,7 +245,8 @@ impl> Daemon { DeskRequest::Answer { subject, authority, deciders, request, .. } => { self.mind.desk_answer(&deciders, subject, authority, request, now).0 } - DeskRequest::Forks { .. } | DeskRequest::Fork { .. } => DeskResponse::Refused(MindRefusal::Unavailable { detail: DESK_READS_UNBUILT.into() }), + DeskRequest::Forks { deciders, status, cursor, limit, .. } => self.mind.desk_forks(&deciders, status, cursor, limit), + DeskRequest::Fork { deciders, fork_id, .. } => self.mind.desk_fork(&deciders, &fork_id), } } diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index 2ad1951..b8b48f6 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -20,7 +20,8 @@ use cultnet_rs::{ decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, encode_frame, }; use huginn_mind::desk_wire::{ - DESK_REQUEST_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, RequestSchema, + DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkCursor, + ForkEntry, ForkLimit, ForkList, ListStatus, RequestSchema, }; use huginn_mind::envelope::{OperationFailure, decode_desk_response, decode_response, encode_desk_request, encode_request}; use huginn_mind::eureka_pipeline::{DeskAuthority, PipelineDocument, Short}; @@ -92,6 +93,45 @@ fn answer(who: &str, authority: DeskAuthority, deciders: &[&str], request: ForkA DeskRequest::Answer { mind: slug(INSTANCE), subject: short(who), authority, deciders: deciders.iter().map(|d| short(d)).collect(), request } } +/// A list read by `who` against `deciders`. +fn forks(who: &str, deciders: &[&str], status: Option, cursor: Option, limit: u8) -> DeskRequest { + DeskRequest::Forks { + mind: slug(INSTANCE), + subject: short(who), + deciders: deciders.iter().map(|d| short(d)).collect(), + status, + cursor, + limit: ForkLimit::new(limit).unwrap(), + } +} + +fn forks_all(deciders: &[&str]) -> DeskRequest { + forks("x", deciders, None, None, 50) +} + +fn fork_read(who: &str, deciders: &[&str], fork_id: &str) -> DeskRequest { + DeskRequest::Fork { mind: slug(INSTANCE), subject: short(who), deciders: deciders.iter().map(|d| short(d)).collect(), fork_id: fork_id.into() } +} + +fn page_of(response: DeskResponse) -> (ForkList, u64) { + match response { + DeskResponse::Forks { list, as_of } => (list, as_of), + other => panic!("expected a page, got {other:?}"), + } +} + +fn entry_of(response: DeskResponse) -> (ForkEntry, u64) { + match response { + DeskResponse::Fork { entry, as_of } => (entry, as_of), + other => panic!("expected an entry, got {other:?}"), + } +} + +/// A plain answer to the seeded fork, with a note. +fn noted(hash: &str, request_id: &str, note: &str) -> ForkAnswerRequest { + ForkAnswerRequest { answer: Some(ForkAnswer { choice: Some("A".into()), note: Some(note.into()) }), ..wire(hash, request_id) } +} + fn message(id: &str, request: &DeskRequest) -> CultNetMessage { encode_desk_request(id, request, None).unwrap() } @@ -750,18 +790,153 @@ mod door_smoke { } #[test] - fn forks_and_fork_are_refused_unavailable_while_reads_are_unbuilt() { + fn forks_and_fork_read_the_stored_fork_and_store_nothing() { + let mut rig = rig(my_uid()); + let (before, hash) = (rig.state(), rig.hash.clone()); + let (forks, fork) = rig.serving(|path| (reply_of(path, "m-1", &forks_all(&["x", "y"])), reply_of(path, "m-2", &fork_read("x", &["x", "y"], FORK)))); + let (list, listed_at) = page_of(forks); + let (entry, read_at) = entry_of(fork); + assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.next_cursor), (1, 0, 1, None)); + assert_eq!(list.mind, INSTANCE); + assert_eq!((listed_at, read_at), (before.1 as u64, before.1 as u64), "both read at the head the mind held"); + assert_eq!(list.stamp, format!("s{listed_at}")); + assert_eq!(list.forks[0], entry); + assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), ["x".to_string(), "y".to_string()].as_slice())); + assert_eq!(rig.state(), before, "a read stores nothing"); + assert!(rig.index.seen.lock().unwrap().is_empty()); + // An unknown fork is the answer path's refusal. + let unknown = rig.serving(|path| reply_of(path, "m-3", &fork_read("x", &["x"], "eureka-state:question:Q404"))); + assert!(matches!(unknown, DeskResponse::NotStored(ref refusal) if refusal.code == huginn_mind::desk_wire::ForkRefusalCode::ForkUnknown), "{unknown:?}"); + // The mind is the instance's own. + let mut foreign = forks_all(&["x"]); + let DeskRequest::Forks { mind, .. } = &mut foreign else { unreachable!() }; + *mind = slug(OTHER); + let refused = rig.serving(|path| reply_of(path, "m-4", &foreign)); + assert_eq!(refused, DeskResponse::Refused(MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() })); + assert_eq!(rig.state(), before); + } + + /// Ruling `desk-read-visibility-whole-record`: Huginn returns the whole record, notes included, to + /// whoever the socket carries. The subject is not in the deciders; the decider's note is there. + #[test] + fn a_subject_not_in_deciders_reads_the_whole_record_with_the_notes() { + let mut rig = rig(my_uid()); + let hash = rig.hash.clone(); + let note = "Keep it local: the author's call."; + rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer { + mind: slug(INSTANCE), + subject: short("y"), + authority: DeskAuthority::Member, + deciders: vec![short("y")], + request: noted(&hash, R1, note), + }))); + let (list, read) = rig.serving(|path| (reply_of(path, "m-2", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-3", &fork_read("outsider", &["x"], FORK)))); + let (list, _) = page_of(list); + let (read, _) = entry_of(read); + for entry in [&list.forks[0], &read] { + assert_eq!(entry.record.answers.len(), 1); + assert_eq!(entry.record.answers[0].note.as_deref(), Some(note)); + assert_eq!(entry.record.answers[0].answerer, "y"); + assert_eq!(entry.card.deciders, ["x"], "the list is the call's, and only fills the card"); + } + } + + #[test] + fn forks_and_fork_deciders_64_accepted_65_refused() { let mut rig = rig(my_uid()); let before = rig.state(); - let forks = DeskRequest::Forks { mind: slug(INSTANCE), subject: short("x"), deciders: vec![short("x")] }; - let fork = DeskRequest::Fork { mind: slug(INSTANCE), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() }; - let (forks, fork) = rig.serving(|path| (reply_of(path, "m-1", &forks), reply_of(path, "m-2", &fork))); - for response in [forks, fork] { - let DeskResponse::Refused(MindRefusal::Unavailable { detail }) = response else { panic!("{response:?}") }; - assert!(detail.contains("desk reads not built"), "{detail}"); + let names = |n: usize| (0..n).map(|d| format!("d{d}")).collect::>(); + let (sixty_four, sixty_five) = (names(64), names(65)); + let (sixty_four, sixty_five): (Vec<&str>, Vec<&str>) = + (sixty_four.iter().map(String::as_str).collect(), sixty_five.iter().map(String::as_str).collect()); + let accepted = rig.serving(|path| (reply_of(path, "m-1", &forks("d0", &sixty_four, None, None, 50)), reply_of(path, "m-2", &fork_read("d0", &sixty_four, FORK)))); + assert_eq!(page_of(accepted.0).0.forks[0].card.deciders.len(), 64); + assert_eq!(entry_of(accepted.1).0.card.deciders.len(), 64); + for (what, request) in [("forks", forks("d0", &sixty_five, None, None, 50)), ("fork", fork_read("d0", &sixty_five, FORK))] { + let bytes = framed(&message("m-3", &request)); + let refused = rig.serving(|path| failure_of(path, &bytes)); + assert_eq!(refused.code, "payload-not-a-request", "{what}"); + assert!(refused.message.contains("deciders"), "{what}: {}", refused.message); + assert!(!refused.message.contains("d64"), "{what} echoes a decider: {}", refused.message); + } + assert_eq!(rig.state(), before, "nothing stored, head unchanged"); + } + + #[test] + fn forks_and_fork_deciders_empty_or_duplicate_refused() { + let mut rig = rig(my_uid()); + let before = rig.state(); + for (what, list) in [("empty", vec![]), ("duplicate", vec!["x", "x"])] { + for (name, request) in [("forks", forks("x", &list, None, None, 50)), ("fork", fork_read("x", &list, FORK))] { + let bytes = framed(&message("m-1", &request)); + let refused = rig.serving(|path| failure_of(path, &bytes)); + assert_eq!(refused.code, "payload-not-a-request", "{what} {name}"); + assert!(refused.message.contains("deciders"), "{what} {name}: {}", refused.message); + } + } + // One decider is a list. + let ok = rig.serving(|path| reply_of(path, "m-2", &forks_all(&["x"]))); + assert_eq!(page_of(ok).0.forks.len(), 1); + assert_eq!(rig.state(), before); + } + + /// Chat's reads carry no `authority`: CultLib's list and read requests are closed and have none, so a + /// payload that names it is no request. Nothing is answered and the head does not move. + #[test] + fn a_read_that_names_an_authority_is_refused_at_the_door() { + let mut rig = rig(my_uid()); + let before = rig.state(); + for (operation, request) in [("forks", forks_all(&["x"])), ("fork", fork_read("x", &["x"], FORK))] { + let mut value = serde_json::to_value(&request).unwrap(); + let variant = if operation == "forks" { "Forks" } else { "Fork" }; + value[variant]["authority"] = serde_json::json!("Operator"); + let payload = rmp_serde::to_vec_named(&value).unwrap(); + let sent = framed(&raw(DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, operation, "m-1", payload)); + let refused = rig.serving(|path| failure_of(path, &sent)); + assert_eq!(refused.code, "payload-not-a-request", "{operation}"); + assert!(refused.message.contains(variant), "{operation}: {}", refused.message); } assert_eq!(rig.state(), before); - assert!(rig.index.seen.lock().unwrap().is_empty()); + // The same reads without it are answered. + let (list, entry) = rig.serving(|path| (reply_of(path, "m-2", &forks_all(&["x"])), reply_of(path, "m-3", &fork_read("x", &["x"], FORK)))); + assert_eq!((page_of(list).0.forks.len(), entry_of(entry).0.card.fork_id.as_str().to_string()), (1, FORK.to_string())); + } + + /// Fifty wordy forks, one commit. A page of them is past the response bound: the door refuses it by name + /// (the client lowers `limit`), never cuts it, and the smaller page is whole. + #[test] + fn a_page_past_the_response_bound_is_refused_by_name_and_never_cut() { + let mut rig = rig(my_uid()); + let (question, _) = question_and_ruling(); + let PipelineDocument::Question(held) = question else { unreachable!() }; + let documents: Vec = (2..=51) + .map(|n| { + let mut wordy = held.clone(); + wordy.label = format!("Q{n}").as_str().into(); + wordy.question = "Should a slot mark be shared between authors? ".repeat(18).as_str().into(); + PipelineDocument::Question(wordy) + }) + .collect(); + let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), now()).answered(); + assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}"); + let before = rig.state(); + let (big, small) = rig.serving(|path| (reply_of(path, "m-1", &forks_all(&["x"])), reply_of(path, "m-2", &forks("x", &["x"], None, None, 10)))); + let DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes, limit }) = big else { panic!("{big:?}") }; + assert_eq!(limit, DESK_MAX_RESPONSE_BYTES as u64); + assert!(bytes > limit); + let (small, _) = page_of(small); + assert_eq!((small.forks.len(), small.open_count), (10, 51)); + assert!(small.next_cursor.is_some()); + assert_eq!(rig.state(), before); + // The bound is exact on a read: a response of N bytes passes at N and is refused at N - 1. + let response = DeskResponse::Forks { list: small, as_of: 1 }; + let size = { + let message = huginn_mind::envelope::encode_desk_response("m-9", "forks", &response, "huginn-yggdrasil").unwrap(); + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap().len() + }; + let at = |limit: usize| decode_desk_response(&crate::desk_door::respond(&response, "m-9", "forks", "huginn-yggdrasil", limit)).unwrap().1.unwrap(); + assert_eq!(at(size), response); + assert!(matches!(at(size - 1), DeskResponse::Refused(MindRefusal::ResponseTooLarge { .. }))); } } diff --git a/crates/huginn-daemon/tests/desk_door.rs b/crates/huginn-daemon/tests/desk_door.rs index c3ba889..1f9eacc 100644 --- a/crates/huginn-daemon/tests/desk_door.rs +++ b/crates/huginn-daemon/tests/desk_door.rs @@ -210,6 +210,17 @@ fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest { } } +fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest { + DeskRequest::Forks { + mind: INSTANCE.into(), + subject: short(who), + deciders: deciders.iter().map(|d| short(d)).collect(), + status: None, + cursor: None, + limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(), + } +} + fn framed(message: &CultNetMessage) -> Vec { encode_frame(&encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap()).unwrap() } @@ -266,14 +277,37 @@ fn the_binary_serves_both_doors_and_survives_restart() { let catalog = format!("{:?}", exchange(&desk, &framed(&catalog_request())).expect("a catalog")); assert!(catalog.contains("huginn.desk_request.v1") && catalog.contains("huginn.desk_response.v1"), "{catalog}"); assert!(!catalog.contains("huginn.mind_request.v1"), "{catalog}"); - // Reads are not built: Unavailable, nothing stored. - let forks = DeskRequest::Forks { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")] }; - let fork = DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() }; - for request in [forks, fork] { - let DeskResponse::Refused(huginn_mind::MindRefusal::Unavailable { detail }) = ask(&desk, "m-r", &request) else { - panic!("a read is refused unavailable") + // The reads answer, store nothing, and carry the whole record to a subject the deciders do not name. + let (list, listed_at) = match ask(&desk, "m-r1", &forks_request("x", &["x"])) { + DeskResponse::Forks { list, as_of } => (list, as_of), + other => panic!("{other:?}"), + }; + assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.stamp.clone()), (1, 0, 1, format!("s{listed_at}"))); + assert_eq!(list.forks[0].card.options_hash, hash); + let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() }) + else { + panic!("a read of the seeded fork") + }; + assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at)); + assert_eq!(daemon.documents(), seeded); + // `authority` is no field of a read: the payload is refused whole and nothing is answered or stored. + for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })] { + let mut value = serde_json::to_value(&request).unwrap(); + let variant = if operation == "forks" { "Forks" } else { "Fork" }; + value[variant]["authority"] = serde_json::json!("Operator"); + let message = CultNetMessage::OperationRequest { + message_id: "m-ra".into(), + service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(), + operation: operation.into(), + payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(), + payload_encoding: "messagepack-base64".into(), + payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()), + source_runtime_id: None, + target_runtime_id: None, }; - assert!(detail.contains("desk reads not built"), "{detail}"); + let reply = exchange(&desk, &framed(&message)).expect("the door replied"); + let failure = decode_desk_response(&reply).unwrap().1.unwrap_err(); + assert_eq!(failure.code, "payload-not-a-request", "{operation}"); } assert_eq!(daemon.documents(), seeded); // An answer, then a stale one. @@ -285,6 +319,16 @@ fn the_binary_serves_both_doors_and_survives_restart() { assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1"))); let stored = daemon.documents(); assert_eq!(stored, seeded + 2); + // A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code. + let DeskResponse::Fork { entry, .. } = + ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() }) + else { + panic!("a read") + }; + let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_str(), answer.code)).collect(); + assert_eq!(seen, [("n1", "x", None), ("n2", "x", Some(ForkAnswerCode::StaleFork))]); + assert_eq!((entry.record.counted_answer_id.as_deref(), entry.card.deciders.as_slice()), (Some("n1"), ["y".to_string()].as_slice())); + assert_eq!(daemon.documents(), stored); // A stop takes the socket with it; the stored answers are the retry's memory. daemon.signal("TERM"); diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 028f424..b5a9da9 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -1,8 +1,10 @@ //! The fork desk's answer wire, Huginn's half: Libby's answer-side documents -//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at 6231ecad) +//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at e57fbaee) //! as Rust types, the one conversion between her `n` answer ids and the //! record keys, the projection of a stored answer, the refusal mapping and the //! showability predicate, and `Mind::desk_answer`, the one call that runs them in order. +//! The read side (card, record, entry and list, `Mind::desk_forks` and `Mind::desk_fork`) +//! is the child module `reads`. //! //! `Mind::answer_desk` stays the only owner of an answer's outcome, sequence, //! request id lookup, replay and conflict; this module converts, projects and @@ -11,7 +13,10 @@ //! 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 request type, its -//! constants and the published schemas are here; the reads and the socket are other cuts. +//! constants and the published schemas are here; the socket is the daemon's. + +mod reads; +pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkEntry, ForkLimit, ForkList, ForkOption, ForkRecord, ForkRef, ListSchema, ListStatus, RecordSchema}; use chrono::{DateTime, Utc}; use eureka_pipeline::{ @@ -169,13 +174,24 @@ pub struct ForkRefusal { /// What the door answers one desk request with. `Answered` is a stored answer, refused ones /// included (a stale fork, a non-decider and the rest are stored), fresh or replayed alike. -/// `NotStored` is a request that stored nothing. `Refused` is any other Mind refusal; an -/// `Unavailable` is Bifrost's to turn into its 503, and Huginn writes no document for it. +/// `Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were +/// all read at. `NotStored` is a request that stored nothing (an unknown fork included). +/// `Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and +/// Huginn writes no document for it. +/// +/// The trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork` +/// carry the WHOLE record, every answer's note included, to whatever the peer-checked socket +/// carries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a +/// stripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable +/// only by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a +/// second reader of that socket is a note leak. // One value per call, built and encoded once; a box would only add a deref to every reader. #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum DeskResponse { Answered { answer: ForkDeskAnswer, counted_answer_id: Option, as_of: u64 }, + Forks { list: ForkList, as_of: u64 }, + Fork { entry: ForkEntry, as_of: u64 }, NotStored(ForkRefusal), Refused(MindRefusal), } @@ -184,7 +200,7 @@ 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::Answered { .. } | Self::Forks { .. } | Self::Fork { .. } => "accepted", Self::NotStored(_) | Self::Refused(_) => "rejected", } } @@ -249,8 +265,17 @@ 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 (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. +/// `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, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and +/// `read_request` are closed and have none; one that names it is refused whole). Their `subject` +/// and `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability +/// gate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat +/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn +/// answers every read with the whole record and Bifrost applies visibility; see `DeskResponse` +/// for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the +/// reads spec. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub enum DeskRequest { @@ -269,6 +294,13 @@ pub enum DeskRequest { #[serde(deserialize_with = "deciders")] #[schemars(schema_with = "deciders_schema")] deciders: Vec, + #[serde(deserialize_with = "required")] + #[schemars(required)] + status: Option, + #[serde(deserialize_with = "required")] + #[schemars(required)] + cursor: Option, + limit: ForkLimit, }, Fork { mind: Slug, @@ -1527,7 +1559,7 @@ mod tests { 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::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() }, DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() }, ] } diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs new file mode 100644 index 0000000..b327170 --- /dev/null +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -0,0 +1,784 @@ +//! The fork desk's read side: the card, the record, the entry and the list as Libby's closed +//! map-mode documents (CultLib `fork-card.schema.json` at e57fbaee), their projection from the +//! stored question and desk answers, and the two Mind calls the door dispatches to, +//! `Mind::desk_forks` and `Mind::desk_fork`. These docs, the doc of `DeskRequest` and the doc of +//! `DeskResponse` are the reads spec. +//! +//! Everything in one response is read at ONE snapshot, `image.head`, returned as `as_of`; the +//! list's `stamp` is `s`. The calls take the call's `deciders` and nothing about who is +//! asking: `deciders` fills each card's `deciders` and is the showability gate's input, and no +//! call is handed the subject, so no rule here can read who the reader is. Huginn returns the +//! whole record, every note included; Bifrost applies visibility (ruling +//! `desk-read-visibility-whole-record`). Nothing is stored. + +use super::*; + +/// The card document's schema id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum CardSchema { + #[serde(rename = "gamecult.fork.card.v0")] + V0, +} + +/// The record document's schema id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum RecordSchema { + #[serde(rename = "gamecult.fork.record.v0")] + V0, +} + +/// The entry document's schema id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum EntrySchema { + #[serde(rename = "gamecult.fork.entry.v0")] + V0, +} + +/// The list document's schema id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum ListSchema { + #[serde(rename = "gamecult.fork.list.v0")] + V0, +} + +/// One shown option. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkOption { + pub id: String, + pub text: String, +} + +/// A typed reference, as the question holds it (`raised_in`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkRef { + pub kind: String, + pub id: String, +} + +/// Whether a ruling closed the fork. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CardStatus { + Open, + Closed, +} + +/// `gamecult.fork.card.v0`: closed, her field order. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkCard { + #[serde(rename = "schemaVersion")] + pub schema_version: CardSchema, + pub fork_id: String, + pub title: String, + pub context: String, + pub options: Vec, + pub recommendation: String, + pub depends: Vec, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub leans: Option, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub flag: Option, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub raised_in: Option, + pub deciders: Vec, + pub status: CardStatus, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub ruling: Option, + pub ruled_from: Vec, + pub order: u64, + pub options_hash: String, +} + +/// `gamecult.fork.record.v0`: the answers in admission order and the derived counted answer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkRecord { + #[serde(rename = "schemaVersion")] + pub schema_version: RecordSchema, + pub fork_id: String, + pub options_hash: String, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub counted_answer_id: Option, + pub answers: Vec, +} + +/// `gamecult.fork.entry.v0`: a card and its record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkEntry { + #[serde(rename = "schemaVersion")] + pub schema_version: EntrySchema, + pub card: ForkCard, + pub record: ForkRecord, +} + +/// `gamecult.fork.list.v0`: a page of entries, the counts over the showable set and the stamp. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForkList { + #[serde(rename = "schemaVersion")] + pub schema_version: ListSchema, + pub mind: String, + pub stamp: String, + pub open_count: u64, + pub recorded_count: u64, + pub forks: Vec, + #[serde(deserialize_with = "required")] + #[schemars(required)] + pub next_cursor: Option, +} + +/// Which forks a list asks for; absent is both. `recorded` is a closed card. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ListStatus { + Open, + Recorded, +} + +/// `c`, N from 1: the cards whose `order` is below N. Built only through `new` or decode, so +/// a held cursor is always a well-formed one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ForkCursor(u64); + +impl ForkCursor { + /// `None` for 0: `c0` is no cursor (her pattern is `^c[1-9][0-9]*$`). + pub fn new(order: u64) -> Option { + (order > 0).then_some(Self(order)) + } + + pub fn order(self) -> u64 { + self.0 + } +} + +impl Serialize for ForkCursor { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format!("c{}", self.0)) + } +} + +impl<'de> Deserialize<'de> for ForkCursor { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + let digits = text.strip_prefix('c').filter(|digits| !digits.starts_with('0') && digits.bytes().all(|byte| byte.is_ascii_digit())); + digits.and_then(|digits| digits.parse().ok()).and_then(Self::new).ok_or_else(|| serde::de::Error::custom("a cursor is c, N from 1")) + } +} + +impl JsonSchema for ForkCursor { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ForkCursor".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "type": "string", "pattern": "^c[1-9][0-9]*$" }) + } +} + +/// Most entries one page holds (CultLib `list.forks` `maxItems`). +pub const DESK_PAGE_MAX: u8 = 50; + +/// A page size, 0 to `DESK_PAGE_MAX`; 0 asks for the counts and the stamp only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ForkLimit(u8); + +impl ForkLimit { + pub fn new(limit: u8) -> Option { + (limit <= DESK_PAGE_MAX).then_some(Self(limit)) + } + + pub fn get(self) -> u8 { + self.0 + } +} + +impl Serialize for ForkLimit { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(self.0) + } +} + +impl<'de> Deserialize<'de> for ForkLimit { + fn deserialize>(deserializer: D) -> Result { + Self::new(u8::deserialize(deserializer)?).ok_or_else(|| serde::de::Error::custom("a limit is 0 to 50")) + } +} + +impl JsonSchema for ForkLimit { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ForkLimit".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "type": "integer", "minimum": 0, "maximum": DESK_PAGE_MAX }) + } +} + +impl Mind { + /// STUB + pub fn desk_forks(&self, _deciders: &[Short], _status: Option, _cursor: Option, _limit: ForkLimit) -> DeskResponse { + DeskResponse::NotStored(fork_unknown()) + } + + /// STUB + pub fn desk_fork(&self, _deciders: &[Short], _fork_id: &str) -> DeskResponse { + DeskResponse::NotStored(fork_unknown()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::desk::options_hash; + use crate::envelope::{decode_desk_request, encode_desk_request, encode_desk_response}; + use crate::fixtures::*; + use crate::store::test_stores::MemoryStore; + use crate::PipelineAdmissionOutcome; + use eureka_pipeline::{Line, PipelineRef, QuestionOption}; + use serde_json::{Value, json}; + + use PipelineDocument as D; + + const CAMPAIGN_ROOT: &str = "eureka-state"; + const LIST: [&str; 3] = ["x", "y", "op"]; + + fn fork(n: u32) -> String { + format!("{CAMPAIGN_ROOT}:question:Q{n}") + } + + /// A desk of `n` questions, Q1 oldest, each admitted in its own commit so that each has its own order. + fn desk(n: u32) -> Mind { + let mut mind = seeded(); + for at in 1..=n { + committed(admit(&mut mind, vec![question_n(at)])); + } + mind + } + + fn list(who: &[&str]) -> Vec { + who.iter().map(|id| s(id)).collect() + } + + fn head(mind: &Mind) -> u64 { + mind.image().unwrap().head + } + + fn hash_of(mind: &Mind, fork: &str) -> String { + let image = mind.image().unwrap(); + let docs = Docs::at(image, image.head); + let Some(D::Question(question)) = docs.find(PipelineKind::Question, fork) else { panic!("no question {fork}") }; + options_hash(question).0 + } + + fn request_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + format!("00000000-0000-4000-8000-{:012x}", NEXT.fetch_add(1, Ordering::Relaxed)) + } + + /// `who`'s answer to `fork` under `hash`, choice A and a note, listing `deciders`. + fn answer(mind: &mut Mind, who: &str, deciders: &[&str], fork: &str, hash: &str, note: Option<&str>) -> DeskResponse { + let request = ForkAnswerRequest { + schema_version: RequestSchema::V0, + fork_id: fork.into(), + options_hash: hash.into(), + answer: Some(ForkAnswer { choice: Some("A".into()), note: note.map(str::to_string) }), + retracted: false, + retracts: None, + answered_at: "2026-09-16T11:00:00Z".into(), + request_id: request_id(), + }; + mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now()).0 + } + + fn close(mind: &mut Mind, fork: &str, label: &str) { + let mut ruled = ruling(label); + ruled.answers = Some(s(fork)); + ruled.choice = Some(l("A")); + committed(admit(mind, vec![D::Ruling(ruled)])); + } + + fn page(response: DeskResponse) -> (ForkList, u64) { + match response { + DeskResponse::Forks { list, as_of } => (list, as_of), + other => panic!("expected a page, got {other:?}"), + } + } + + fn entry(response: DeskResponse) -> (ForkEntry, u64) { + match response { + DeskResponse::Fork { entry, as_of } => (entry, as_of), + other => panic!("expected an entry, got {other:?}"), + } + } + + fn unknown(response: DeskResponse) { + assert_eq!(response, DeskResponse::NotStored(fork_unknown())); + } + + fn ids(list: &ForkList) -> Vec { + list.forks.iter().map(|entry| entry.card.fork_id.clone()).collect() + } + + fn limit(n: u8) -> ForkLimit { + ForkLimit::new(n).unwrap() + } + + fn all(mind: &Mind, n: u8) -> ForkList { + page(mind.desk_forks(&list(&LIST), None, None, limit(n))).0 + } + + // ---- the reads, one snapshot ---- + + #[test] + fn a_stale_answer_then_the_read_cards_hash_counts() { + let mut mind = desk(1); + let stale = answer(&mut mind, "x", &LIST, &fork(1), &"0".repeat(64), None); + let DeskResponse::Answered { answer: stale, .. } = stale else { panic!("a stale answer is stored") }; + assert_eq!(stale.code, Some(ForkAnswerCode::StaleFork)); + let (shown, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + assert_eq!(shown.card.options_hash, hash_of(&mind, &fork(1))); + assert_ne!(shown.card.options_hash, "0".repeat(64)); + assert_eq!((shown.record.answers.len(), shown.record.counted_answer_id.clone()), (1, None)); + assert_eq!(shown.record.options_hash, shown.card.options_hash); + // The hash the card showed counts. + let DeskResponse::Answered { answer: counted, counted_answer_id, .. } = answer(&mut mind, "x", &LIST, &fork(1), &shown.card.options_hash, None) else { + panic!("stored") + }; + assert_eq!((counted.outcome.as_str(), counted.answer_id.as_str()), ("Counted", "n2")); + assert_eq!(counted_answer_id.as_deref(), Some("n2")); + let (after, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2")); + let names: Vec<_> = after.record.answers.iter().map(|answer| answer.answer_id.as_str()).collect(); + assert_eq!(names, ["n1", "n2"], "answers in admission order, named n"); + } + + #[test] + fn list_then_read_at_one_stamp() { + let mut mind = desk(3); + let (first, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(2))); + assert_eq!(as_of, head(&mind)); + assert_eq!(first.stamp, format!("s{as_of}")); + assert_eq!(first.mind, INSTANCE); + assert_eq!(ids(&first), [fork(3), fork(2)]); + let next = first.next_cursor.expect("one more fork waits"); + assert_eq!(next.order(), first.forks[1].card.order); + let (second, _) = page(mind.desk_forks(&list(&LIST), None, Some(next), limit(2))); + assert_eq!((ids(&second), second.next_cursor), (vec![fork(1)], None)); + // The read at that stamp is the entry the list carried. + let (read, read_at) = entry(mind.desk_fork(&list(&LIST), &fork(2))); + assert_eq!(read_at, as_of); + assert_eq!(read, first.forks[1]); + // Writing moves the stamp, and only then. + let hash = hash_of(&mind, &fork(2)); + answer(&mut mind, "x", &LIST, &fork(2), &hash, None); + let (moved, moved_at) = page(mind.desk_forks(&list(&LIST), None, None, limit(2))); + assert_eq!((moved.stamp.clone(), moved_at), (format!("s{}", head(&mind)), head(&mind))); + assert_ne!(moved.stamp, first.stamp); + assert_eq!(moved.forks[1].record.answers.len(), 1); + } + + #[test] + fn forks_come_newest_first_by_order_and_the_cursor_is_strictly_below() { + let mind = desk(4); + let everything = all(&mind, 50); + assert_eq!(ids(&everything), [fork(4), fork(3), fork(2), fork(1)]); + let orders: Vec = everything.forks.iter().map(|entry| entry.card.order).collect(); + assert!(orders.windows(2).all(|pair| pair[0] > pair[1]), "{orders:?}"); + assert_eq!(everything.next_cursor, None, "the last page has no cursor"); + // A cursor names an order and returns the cards strictly below it. + for (at, expected) in [(0, 3), (1, 2), (2, 1), (3, 0)] { + let below = ForkCursor::new(orders[at]).unwrap(); + let (rest, _) = page(mind.desk_forks(&list(&LIST), None, Some(below), limit(50))); + assert_eq!(rest.forks.len(), expected, "below order {}", orders[at]); + assert!(rest.forks.iter().all(|entry| entry.card.order < orders[at])); + } + // A page that stops short names the last order it returned. + let (short, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(1))); + assert_eq!((ids(&short), short.next_cursor.map(ForkCursor::order)), (vec![fork(4)], Some(orders[0]))); + // A page that ends exactly on the last fork has no cursor. + let (exact, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(4))); + assert_eq!((exact.forks.len(), exact.next_cursor), (4, None)); + let (over, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50))); + assert_eq!(over.forks.len(), 4); + } + + #[test] + fn status_picks_open_or_recorded_and_the_counts_ignore_it_and_the_page() { + let mut mind = desk(5); + close(&mut mind, &fork(1), "R1"); + close(&mut mind, &fork(4), "R4"); + let expected = (3, 2); + for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { + for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] { + for size in [0, 1, 50] { + let (shown, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(size))); + assert_eq!((shown.open_count, shown.recorded_count), expected, "{status:?} {cursor:?} {size}"); + assert!(shown.forks.len() <= size as usize); + for entry in &shown.forks { + let want = match status { + None => entry.card.status, + Some(ListStatus::Open) => CardStatus::Open, + Some(ListStatus::Recorded) => CardStatus::Closed, + }; + assert_eq!(entry.card.status, want, "{status:?}"); + } + } + } + } + let (open, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Open), None, limit(50))); + assert_eq!(ids(&open), [fork(5), fork(3), fork(2)]); + let (recorded, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Recorded), None, limit(50))); + assert_eq!(ids(&recorded), [fork(4), fork(1)]); + // A cursor and a status together: the cards below the cursor that have the status. + let below_four = ForkCursor::new(recorded.forks[0].card.order); + let (rest, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Open), below_four, limit(50))); + assert_eq!(ids(&rest), [fork(3), fork(2)]); + } + + #[test] + fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() { + let mind = desk(3); + let (counts, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(0))); + assert_eq!((counts.forks.len(), counts.next_cursor), (0, None)); + assert_eq!((counts.open_count, counts.recorded_count), (3, 0)); + assert_eq!((counts.stamp, as_of), (format!("s{}", head(&mind)), head(&mind))); + let (one, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(1))); + assert_eq!(one.forks.len(), 1, "limit 1 is one entry, not none"); + } + + #[test] + fn a_desk_with_no_forks_is_an_empty_page() { + let mind = seeded(); + let (empty, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(10))); + assert_eq!((empty.open_count, empty.recorded_count, empty.forks.len(), empty.next_cursor), (0, 0, 0, None)); + assert_eq!(empty.stamp, format!("s{as_of}")); + } + + #[test] + fn an_unshowable_fork_is_not_counted_not_listed_and_unknown_on_a_read() { + let mut mind = desk(2); + // A question one decider cannot show: a blank depends line (the gate is `showable`). + let mut blank = question("Q3", &["A", "B"], "A"); + let D::Question(held) = &mut blank else { unreachable!() }; + held.depends = vec!["Cut 3a".into(), "".into()]; + let admitted = admit(&mut mind, vec![blank]); + let unshowable = if matches!(admitted, PipelineAdmissionOutcome::Committed { .. }) { + fork(3) + } else { + let mut blank = question("Q3", &["A", "B"], "A"); + let D::Question(held) = &mut blank else { unreachable!() }; + held.options[0] = QuestionOption { label: l("A"), text: "".into() }; + committed(admit(&mut mind, vec![blank])); + fork(3) + }; + let shown = all(&mind, 50); + assert_eq!(ids(&shown), [fork(2), fork(1)]); + assert_eq!((shown.open_count, shown.recorded_count), (2, 0), "the count is over the showable set"); + unknown(mind.desk_fork(&list(&LIST), &unshowable)); + // No decider, nothing is shown at all: the gate needs one. + let (none, _) = page(mind.desk_forks(&[], None, None, limit(50))); + assert_eq!((none.forks.len(), none.open_count, none.recorded_count), (0, 0, 0)); + unknown(mind.desk_fork(&[], &fork(1))); + } + + #[test] + fn an_unknown_fork_is_fork_unknown_whatever_it_is_called() { + let mut mind = desk(1); + close(&mut mind, &fork(1), "R1"); + let ruling_key = format!("{CAMPAIGN_ROOT}:ruling:R1"); + for id in [fork(9).as_str(), "", "Q1", "no-colons", "eureka-state:question:", ruling_key.as_str(), &"x".repeat(5000), "other-campaign:question:Q1"] { + unknown(mind.desk_fork(&list(&LIST), id)); + } + // And nothing was written by looking. + let before = head(&mind); + let _ = mind.desk_fork(&list(&LIST), &fork(1)); + let _ = mind.desk_forks(&list(&LIST), None, None, limit(50)); + assert_eq!(head(&mind), before); + } + + // ---- the trust boundary: the whole record, whoever asks ---- + + #[test] + fn the_whole_record_comes_back_whoever_the_deciders_list_leaves_out() { + let mut mind = desk(2); + let hash = hash_of(&mind, &fork(1)); + let note = "Keep it local: a slot mark is the author's call."; + // Y, a decider of that call, answers with a note; a stranger is refused with a note too. + answer(&mut mind, "y", &LIST, &fork(1), &hash, Some(note)); + answer(&mut mind, "stranger", &LIST, &fork(1), &hash, Some("a refused person's words")); + // The reads carry another list that names neither of them: no call is handed a subject. + let narrow = list(&["x"]); + let (read, _) = entry(mind.desk_fork(&narrow, &fork(1))); + let notes: Vec<_> = read.record.answers.iter().map(|answer| (answer.answerer.as_str(), answer.note.as_deref())).collect(); + assert_eq!(notes, [("y", Some(note)), ("stranger", Some("a refused person's words"))]); + let (listed, _) = page(mind.desk_forks(&narrow, None, None, limit(50))); + assert_eq!(listed.forks[1], read, "the list carries the same whole record"); + // The list only fills the card. + assert_eq!(read.card.deciders, ["x"]); + let (wide, _) = entry(mind.desk_fork(&list(&["x", "y", "z"]), &fork(1))); + assert_eq!(wide.card.deciders, ["x", "y", "z"]); + assert_eq!(ForkEntry { card: ForkCard { deciders: read.card.deciders.clone(), ..wide.card.clone() }, ..wide }, read, "nothing else moved"); + } + + // ---- the projection ---- + + #[test] + fn the_card_is_the_question_as_shown() { + let mut mind = seeded(); + let mut shown = question("Q1", &["A", "B"], "B"); + let D::Question(held) = &mut shown else { unreachable!() }; + held.title = "Share the mark?".into(); + held.question = "Should a slot mark be shared?".into(); + held.options = vec![QuestionOption { label: l("A"), text: "Share it.".into() }, QuestionOption { label: l("B"), text: "Keep it local.".into() }]; + held.depends = vec!["eureka-state:question:other".into(), "a prose line".into()]; + held.raised_in = Some(PipelineRef { kind: PipelineKind::Ruling, id: s("eureka-state:ruling:R0") }); + committed(admit(&mut mind, vec![D::Ruling(ruling("R0"))])); + committed(admit(&mut mind, vec![shown])); + let (read, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + let card = &read.card; + assert_eq!((card.fork_id.as_str(), card.title.as_str(), card.context.as_str()), (fork(1).as_str(), "Share the mark?", "Should a slot mark be shared?")); + let options: Vec<_> = card.options.iter().map(|option| (option.id.as_str(), option.text.as_str())).collect(); + assert_eq!(options, [("A", "Share it."), ("B", "Keep it local.")]); + assert_eq!(card.recommendation, "B"); + assert_eq!(card.depends, ["eureka-state:question:other", "a prose line"]); + assert_eq!(card.raised_in, Some(ForkRef { kind: "Ruling".into(), id: "eureka-state:ruling:R0".into() })); + assert_eq!((card.leans.clone(), card.flag.clone(), card.status, card.ruling.clone(), card.ruled_from.clone()), (None, None, CardStatus::Open, None, vec![])); + assert_eq!(card.deciders, LIST); + assert_eq!(card.options_hash, hash_of(&mind, &fork(1))); + assert_eq!(card.order, mind.image().unwrap().row(mind.image().unwrap().id_of(PipelineKind::Question, &fork(1)).unwrap()).ordinal); + assert_eq!(read.record.fork_id, fork(1)); + assert_eq!((read.record.counted_answer_id, read.record.answers.len()), (None, 0)); + } + + #[test] + fn a_ruling_closes_the_card_and_names_itself() { + let mut mind = desk(2); + close(&mut mind, &fork(1), "R1"); + let (closed, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + assert_eq!((closed.card.status, closed.card.ruling.as_deref()), (CardStatus::Closed, Some("eureka-state:ruling:R1"))); + let (open, _) = entry(mind.desk_fork(&list(&LIST), &fork(2))); + assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None)); + } + + #[test] + fn the_counted_answer_is_derived_at_the_head_the_read_was_made() { + let mut mind = desk(1); + let hash = hash_of(&mind, &fork(1)); + answer(&mut mind, "x", &LIST, &fork(1), &hash, None); + answer(&mut mind, "y", &LIST, &fork(1), &hash, None); + let (read, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + assert_eq!(read.record.counted_answer_id.as_deref(), Some("n1")); + let outcomes: Vec<_> = read.record.answers.iter().map(|answer| (answer.outcome.as_str(), answer.of.as_deref())).collect(); + assert_eq!(outcomes, [("Counted", None), ("Revision", Some("n1"))]); + // Retracting the counted answer moves the count to the next. + let retract = ForkAnswerRequest { + schema_version: RequestSchema::V0, + fork_id: fork(1), + options_hash: hash, + answer: None, + retracted: true, + retracts: Some("n1".into()), + answered_at: "2026-09-16T11:00:00Z".into(), + request_id: request_id(), + }; + let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now()).0; + assert!(matches!(stored, DeskResponse::Answered { .. })); + let (after, _) = entry(mind.desk_fork(&list(&LIST), &fork(1))); + assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2")); + } + + // ---- Libby's vectors: the entry and list bytes ---- + + fn transport() -> Value { + serde_json::from_str::(include_str!("../../vectors/fork-card-vectors.json")).unwrap()["transport"].clone() + } + + fn bytes_of(hex: &str) -> Vec { + (0..hex.len()).step_by(2).map(|at| u8::from_str_radix(&hex[at..at + 2], 16).unwrap()).collect() + } + + fn encode(value: &T) -> Vec { + rmp_serde::to_vec_named(value).unwrap() + } + + #[test] + fn entry_and_list_vectors_round_trip_byte_for_byte() { + let file = transport(); + let entries = file["entry"]["valid"].as_array().unwrap(); + assert_eq!(entries.len(), 3); + for row in entries { + let bytes = bytes_of(row["messagePackHex"].as_str().unwrap()); + let decoded: ForkEntry = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(encode(&decoded), bytes, "{}", row["name"]); + assert_eq!(decoded, serde_json::from_value::(row["message"].clone()).unwrap(), "{}", row["name"]); + } + let scenarios = file["listScenarios"].as_array().unwrap(); + assert_eq!(scenarios.len(), 5); + let mut steps = 0; + for scenario in scenarios { + for step in scenario["steps"].as_array().unwrap() { + let bytes = bytes_of(step["responseHex"].as_str().unwrap()); + let decoded: ForkList = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(encode(&decoded), bytes, "{}", scenario["name"]); + assert_eq!(decoded, serde_json::from_value::(step["response"].clone()).unwrap()); + steps += 1; + } + } + assert_eq!(steps, 7); + } + + #[test] + fn invalid_entries_and_lists_do_not_decode_as_documents() { + let file = transport(); + // Closed documents: a key outside the set, a missing key and a bad cursor are not documents. + let mut entry = file["entry"]["valid"][0]["message"].clone(); + entry["extra"] = json!(1); + assert!(serde_json::from_value::(entry).is_err()); + let mut entry = file["entry"]["valid"][0]["message"].clone(); + entry.as_object_mut().unwrap().remove("record"); + assert!(serde_json::from_value::(entry).is_err()); + let mut card = file["entry"]["valid"][0]["message"]["card"].clone(); + card.as_object_mut().unwrap().remove("leans"); + assert!(serde_json::from_value::(card).is_err(), "a nullable key is still written"); + for row in file["listInvalid"].as_array().unwrap() { + if row["name"] == "next_cursor_malformed" { + assert!(serde_json::from_value::(row["message"].clone()).is_err(), "{}", row["name"]); + } + } + } + + /// A card-shaped projection of the vector's list step reproduces the vector's request fields. + #[test] + fn the_list_request_vectors_decode_into_the_flat_variant_fields() { + let file = transport(); + let flat = |message: &Value| { + json!({ "Forks": { + "mind": message["mind"], "subject": "s", "deciders": ["s"], + "status": message["status"], "cursor": message["cursor"], "limit": message["limit"] + }}) + }; + let valid = file["listRequest"]["valid"].as_array().unwrap(); + assert_eq!(valid.len(), 3); + for row in valid { + let decoded: DeskRequest = serde_json::from_value(flat(&row["message"])).unwrap_or_else(|error| panic!("{}: {error}", row["name"])); + let DeskRequest::Forks { status, cursor, limit, .. } = decoded else { panic!() }; + assert_eq!(serde_json::to_value(status).unwrap(), row["message"]["status"], "{}", row["name"]); + assert_eq!(serde_json::to_value(cursor).unwrap(), row["message"]["cursor"], "{}", row["name"]); + assert_eq!(serde_json::to_value(limit).unwrap(), row["message"]["limit"], "{}", row["name"]); + } + let mut refused = 0; + for row in file["listRequest"]["invalid"].as_array().unwrap() { + // The envelope carries the mind and the closed set; the fields that are the list's are these. + if ["limit_51", "limit_negative", "status_all", "cursor_malformed", "cursor_zero"].contains(&row["name"].as_str().unwrap()) { + assert!(serde_json::from_value::(flat(&row["message"])).is_err(), "{}", row["name"]); + refused += 1; + } + } + assert_eq!(refused, 5); + } + + fn forks_json(status: Value, cursor: Value, limit: Value) -> Value { + json!({ "Forks": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "status": status, "cursor": cursor, "limit": limit } }) + } + + #[test] + fn page_limit_is_zero_to_fifty_and_nothing_else() { + for ok in [0, 1, 49, 50] { + assert!(serde_json::from_value::(forks_json(Value::Null, Value::Null, json!(ok))).is_ok(), "{ok}"); + } + for bad in [json!(51), json!(255), json!(256), json!(-1), json!(1.5), json!("10"), Value::Null] { + assert!(serde_json::from_value::(forks_json(Value::Null, Value::Null, bad.clone())).is_err(), "{bad}"); + } + assert_eq!(ForkLimit::new(DESK_PAGE_MAX).map(ForkLimit::get), Some(50)); + assert_eq!(ForkLimit::new(DESK_PAGE_MAX + 1), None); + } + + #[test] + fn a_cursor_is_c_and_a_positive_decimal() { + for ok in ["c1", "c9", "c10", "c412", "c18446744073709551615"] { + let read: DeskRequest = serde_json::from_value(forks_json(Value::Null, json!(ok), json!(5))).unwrap_or_else(|error| panic!("{ok}: {error}")); + let DeskRequest::Forks { cursor, .. } = read else { panic!() }; + assert_eq!(serde_json::to_value(cursor).unwrap(), json!(ok)); + } + for bad in ["c0", "c01", "c", "", "4", "C4", "c-1", "c+1", "c 4", "c4 ", "cx", "c18446744073709551616", "next"] { + assert!(serde_json::from_value::(forks_json(Value::Null, json!(bad), json!(5))).is_err(), "{bad:?}"); + } + assert_eq!(ForkCursor::new(0), None); + } + + #[test] + fn status_is_open_recorded_or_null_and_every_key_is_written() { + for ok in [json!("open"), json!("recorded"), Value::Null] { + assert!(serde_json::from_value::(forks_json(ok.clone(), Value::Null, json!(5))).is_ok(), "{ok}"); + } + for bad in [json!("all"), json!("closed"), json!(""), json!("Open"), json!(1)] { + assert!(serde_json::from_value::(forks_json(bad.clone(), Value::Null, json!(5))).is_err(), "{bad}"); + } + for missing in ["status", "cursor", "limit"] { + let mut value = forks_json(Value::Null, Value::Null, json!(5)); + value["Forks"].as_object_mut().unwrap().remove(missing); + assert!(serde_json::from_value::(value).is_err(), "{missing} may not be left out"); + } + } + + /// The reads carry no `authority` (CultLib's `list_request` and `read_request` are closed and have none). + #[test] + fn a_read_that_names_an_authority_is_not_a_request() { + let mut forks = forks_json(Value::Null, Value::Null, json!(5)); + let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "fork_id": fork(1) } }); + assert!(serde_json::from_value::(forks.clone()).is_ok() && serde_json::from_value::(fork.clone()).is_ok()); + for authority in [json!("Operator"), json!("Member"), Value::Null] { + forks["Forks"]["authority"] = authority.clone(); + fork["Fork"]["authority"] = authority.clone(); + assert!(serde_json::from_value::(forks.clone()).is_err(), "Forks with {authority}"); + assert!(serde_json::from_value::(fork.clone()).is_err(), "Fork with {authority}"); + } + // And through the envelope the door reads. + let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: s("s"), deciders: list(&["s"]), status: None, cursor: None, limit: limit(5) }; + let message = encode_desk_request("m-1", &typed, None).unwrap(); + assert_eq!(decode_desk_request(&message).unwrap().1, typed); + } + + // ---- the response bound ---- + + /// A realistic question: the longest context the stored bound allows is far over this, so a page of + /// these is the ordinary case, not the worst. + fn wordy(n: u32) -> PipelineDocument { + let mut shown = question_n(n); + let D::Question(held) = &mut shown else { unreachable!() }; + held.title = format!("Fork {n}: who owns the shared slot mark when two authors disagree?").as_str().into(); + held.question = "Should a slot mark be shared between authors? ".repeat(18).as_str().into(); + held.options = ["A", "B", "C", "D"].iter().map(|label| QuestionOption { label: l(label), text: format!("Option {label}: {}", "keep it where it was made. ".repeat(3)).as_str().into() }).collect(); + held.depends = (0..3).map(|at| Line::from(format!("eureka-state:question:dependency-{at}").as_str())).collect(); + shown + } + + #[test] + fn a_page_is_measured_against_the_response_bound_and_the_bound_is_the_door_s() { + let mut mind = seeded(); + for n in 1..=50 { + committed(admit(&mut mind, vec![wordy(n)])); + } + let encoded = |size: u8| { + let response = mind.desk_forks(&list(&LIST), None, None, limit(size)); + let message = encode_desk_response("m-1", "forks", &response, "huginn-test").unwrap(); + cultnet_rs::encode_cultnet_message_to_vec(&message, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap().len() + }; + let (zero, ten, fifty) = (encoded(0), encoded(10), encoded(50)); + // The counts always fit; a page fits while its cards do. Fifty cards with a 800-byte context each + // and no answers at all do not: the door refuses that page by name, and the page size is the client's to lower. + assert!(zero < 1000, "{zero}"); + assert!(ten < DESK_MAX_RESPONSE_BYTES, "{ten}"); + assert!(fifty > DESK_MAX_RESPONSE_BYTES, "{fifty} (a limit-50 page of realistic cards is over the bound)"); + } +} diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index a2e9e21..bd056b5 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -428,7 +428,7 @@ mod tests { 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::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone(), status: Some(crate::desk_wire::ListStatus::Open), cursor: crate::desk_wire::ForkCursor::new(7), limit: crate::desk_wire::ForkLimit::new(50).unwrap() }, "Forks"), (DeskRequest::Fork { mind, subject, deciders, fork_id: "eureka-state:question:Q1".into() }, "Fork"), ] } @@ -446,7 +446,7 @@ mod tests { }; let expected = [ ("Answer", vec!["authority", "deciders", "mind", "request", "subject"]), - ("Forks", vec!["deciders", "mind", "subject"]), + ("Forks", vec!["cursor", "deciders", "limit", "mind", "status", "subject"]), ("Fork", vec!["deciders", "fork_id", "mind", "subject"]), ]; for ((request, variant), (named, names)) in desk_requests().iter().zip(expected) { @@ -595,7 +595,9 @@ mod tests { ..request }, }, - DeskRequest::Forks { mind, .. } => DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone() }, + DeskRequest::Forks { mind, status, cursor, limit, .. } => { + DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit } + } DeskRequest::Fork { mind, .. } => { DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) } } diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index a09ba31..b29551e 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -55,7 +55,7 @@ mod read_tests; mod move_tests; pub use desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, options_hash}; -pub use desk_wire::{DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode}; +pub use desk_wire::{DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode}; pub use docs::CitationRole; pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text}; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; diff --git a/crates/huginn-mind/vectors/fork-card-vectors.json b/crates/huginn-mind/vectors/fork-card-vectors.json index 7701ff0..ea738e8 100644 --- a/crates/huginn-mind/vectors/fork-card-vectors.json +++ b/crates/huginn-mind/vectors/fork-card-vectors.json @@ -656,6 +656,107 @@ "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + }, + { + "name": "deciders_at_the_cap_of_64", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "00000001-0000-4000-8000-000000000001", + "00000002-0000-4000-8000-000000000002", + "00000003-0000-4000-8000-000000000003", + "00000004-0000-4000-8000-000000000004", + "00000005-0000-4000-8000-000000000005", + "00000006-0000-4000-8000-000000000006", + "00000007-0000-4000-8000-000000000007", + "00000008-0000-4000-8000-000000000008", + "00000009-0000-4000-8000-000000000009", + "0000000a-0000-4000-8000-00000000000a", + "0000000b-0000-4000-8000-00000000000b", + "0000000c-0000-4000-8000-00000000000c", + "0000000d-0000-4000-8000-00000000000d", + "0000000e-0000-4000-8000-00000000000e", + "0000000f-0000-4000-8000-00000000000f", + "00000010-0000-4000-8000-000000000010", + "00000011-0000-4000-8000-000000000011", + "00000012-0000-4000-8000-000000000012", + "00000013-0000-4000-8000-000000000013", + "00000014-0000-4000-8000-000000000014", + "00000015-0000-4000-8000-000000000015", + "00000016-0000-4000-8000-000000000016", + "00000017-0000-4000-8000-000000000017", + "00000018-0000-4000-8000-000000000018", + "00000019-0000-4000-8000-000000000019", + "0000001a-0000-4000-8000-00000000001a", + "0000001b-0000-4000-8000-00000000001b", + "0000001c-0000-4000-8000-00000000001c", + "0000001d-0000-4000-8000-00000000001d", + "0000001e-0000-4000-8000-00000000001e", + "0000001f-0000-4000-8000-00000000001f", + "00000020-0000-4000-8000-000000000020", + "00000021-0000-4000-8000-000000000021", + "00000022-0000-4000-8000-000000000022", + "00000023-0000-4000-8000-000000000023", + "00000024-0000-4000-8000-000000000024", + "00000025-0000-4000-8000-000000000025", + "00000026-0000-4000-8000-000000000026", + "00000027-0000-4000-8000-000000000027", + "00000028-0000-4000-8000-000000000028", + "00000029-0000-4000-8000-000000000029", + "0000002a-0000-4000-8000-00000000002a", + "0000002b-0000-4000-8000-00000000002b", + "0000002c-0000-4000-8000-00000000002c", + "0000002d-0000-4000-8000-00000000002d", + "0000002e-0000-4000-8000-00000000002e", + "0000002f-0000-4000-8000-00000000002f", + "00000030-0000-4000-8000-000000000030", + "00000031-0000-4000-8000-000000000031", + "00000032-0000-4000-8000-000000000032", + "00000033-0000-4000-8000-000000000033", + "00000034-0000-4000-8000-000000000034", + "00000035-0000-4000-8000-000000000035", + "00000036-0000-4000-8000-000000000036", + "00000037-0000-4000-8000-000000000037", + "00000038-0000-4000-8000-000000000038", + "00000039-0000-4000-8000-000000000039", + "0000003a-0000-4000-8000-00000000003a", + "0000003b-0000-4000-8000-00000000003b", + "0000003c-0000-4000-8000-00000000003c", + "0000003d-0000-4000-8000-00000000003d", + "0000003e-0000-4000-8000-00000000003e", + "0000003f-0000-4000-8000-00000000003f", + "00000040-0000-4000-8000-000000000040" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" } ], "invalid": [ @@ -1389,6 +1490,108 @@ "order": 3, "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" } + }, + { + "name": "deciders_65", + "why": "deciders holds at most 64 (Huginn's bound)", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "00000001-0000-4000-8000-000000000001", + "00000002-0000-4000-8000-000000000002", + "00000003-0000-4000-8000-000000000003", + "00000004-0000-4000-8000-000000000004", + "00000005-0000-4000-8000-000000000005", + "00000006-0000-4000-8000-000000000006", + "00000007-0000-4000-8000-000000000007", + "00000008-0000-4000-8000-000000000008", + "00000009-0000-4000-8000-000000000009", + "0000000a-0000-4000-8000-00000000000a", + "0000000b-0000-4000-8000-00000000000b", + "0000000c-0000-4000-8000-00000000000c", + "0000000d-0000-4000-8000-00000000000d", + "0000000e-0000-4000-8000-00000000000e", + "0000000f-0000-4000-8000-00000000000f", + "00000010-0000-4000-8000-000000000010", + "00000011-0000-4000-8000-000000000011", + "00000012-0000-4000-8000-000000000012", + "00000013-0000-4000-8000-000000000013", + "00000014-0000-4000-8000-000000000014", + "00000015-0000-4000-8000-000000000015", + "00000016-0000-4000-8000-000000000016", + "00000017-0000-4000-8000-000000000017", + "00000018-0000-4000-8000-000000000018", + "00000019-0000-4000-8000-000000000019", + "0000001a-0000-4000-8000-00000000001a", + "0000001b-0000-4000-8000-00000000001b", + "0000001c-0000-4000-8000-00000000001c", + "0000001d-0000-4000-8000-00000000001d", + "0000001e-0000-4000-8000-00000000001e", + "0000001f-0000-4000-8000-00000000001f", + "00000020-0000-4000-8000-000000000020", + "00000021-0000-4000-8000-000000000021", + "00000022-0000-4000-8000-000000000022", + "00000023-0000-4000-8000-000000000023", + "00000024-0000-4000-8000-000000000024", + "00000025-0000-4000-8000-000000000025", + "00000026-0000-4000-8000-000000000026", + "00000027-0000-4000-8000-000000000027", + "00000028-0000-4000-8000-000000000028", + "00000029-0000-4000-8000-000000000029", + "0000002a-0000-4000-8000-00000000002a", + "0000002b-0000-4000-8000-00000000002b", + "0000002c-0000-4000-8000-00000000002c", + "0000002d-0000-4000-8000-00000000002d", + "0000002e-0000-4000-8000-00000000002e", + "0000002f-0000-4000-8000-00000000002f", + "00000030-0000-4000-8000-000000000030", + "00000031-0000-4000-8000-000000000031", + "00000032-0000-4000-8000-000000000032", + "00000033-0000-4000-8000-000000000033", + "00000034-0000-4000-8000-000000000034", + "00000035-0000-4000-8000-000000000035", + "00000036-0000-4000-8000-000000000036", + "00000037-0000-4000-8000-000000000037", + "00000038-0000-4000-8000-000000000038", + "00000039-0000-4000-8000-000000000039", + "0000003a-0000-4000-8000-00000000003a", + "0000003b-0000-4000-8000-00000000003b", + "0000003c-0000-4000-8000-00000000003c", + "0000003d-0000-4000-8000-00000000003d", + "0000003e-0000-4000-8000-00000000003e", + "0000003f-0000-4000-8000-00000000003f", + "00000040-0000-4000-8000-000000000040", + "00000041-0000-4000-8000-000000000041" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } } ] },