From a37bd4f85f13dbe3a6bf8c32e88815ea1a970506 Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 23:50:27 +0000 Subject: [PATCH 1/9] Desk reads: the wire types, the request fields and the failing tests (stub reads answer fork_unknown) Types ForkCard/Record/Entry/List, ForkCursor and ForkLimit validated at decode, DeskRequest::Forks gains status/cursor/limit flat, DeskResponse gains Forks and Fork, the daemon dispatches to Mind::desk_forks/desk_fork (stubs). Vectors vendored at CultLib e57fbaee. The tests pin the rules the next commit implements; DESK_READS_UNBUILT and its test are gone. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/daemon.rs | 9 +- crates/huginn-daemon/src/desk_door_tests.rs | 193 ++++- crates/huginn-daemon/tests/desk_door.rs | 58 +- crates/huginn-mind/src/desk_wire.rs | 48 +- crates/huginn-mind/src/desk_wire/reads.rs | 784 ++++++++++++++++++ crates/huginn-mind/src/envelope.rs | 8 +- crates/huginn-mind/src/lib.rs | 2 +- .../vectors/fork-card-vectors.json | 203 +++++ 8 files changed, 1272 insertions(+), 33 deletions(-) create mode 100644 crates/huginn-mind/src/desk_wire/reads.rs 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" + } } ] }, From 707642bd543ef2ede0231a78d04824e779e36345 Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 23:57:27 +0000 Subject: [PATCH 2/9] Desk reads: Mind::desk_forks and desk_fork project card, record and entry at one snapshot Newest first by admission ordinal, cursor strictly below, counts over the showable set, limit 0 is counts and stamp. Whole record to the peer-checked socket; the trust boundary is pinned in the desk_door module doc and the DeskRequest/DeskResponse docs. Published schemas regenerated. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/desk_door.rs | 11 + crates/huginn-mind/src/desk.rs | 2 +- crates/huginn-mind/src/desk_wire/reads.rs | 129 +++++++- crates/huginn-mind/src/docs.rs | 5 + .../huginn.desk_request.v1.schema.json | 27 +- .../huginn.desk_response.v1.schema.json | 306 +++++++++++++++++- 6 files changed, 469 insertions(+), 11 deletions(-) diff --git a/crates/huginn-daemon/src/desk_door.rs b/crates/huginn-daemon/src/desk_door.rs index 72d9f38..d5f868f 100644 --- a/crates/huginn-daemon/src/desk_door.rs +++ b/crates/huginn-daemon/src/desk_door.rs @@ -9,6 +9,17 @@ //! read and write spends what is left of it, so a peer that trickles bytes is cut off at the //! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name. //! +//! The trust boundary of the reads (ruling `desk-read-visibility-whole-record`). The peer-uid check +//! IS the whole authentication: Huginn must be reachable only by Bifrost's peer-checked socket, so +//! the configured uid is Bifrost's and no other process's. `Forks` and `Fork` return the WHOLE record +//! (cards, outcomes, every decider's note) to whoever passes that check; nothing in Huginn strips a +//! note or gates a read on `subject` or `deciders` (`deciders` only fills the card and the +//! showability gate). Bifrost applies visibility at its own door (a stripped read serves `note: null`, +//! CultLib `fork-card.md`). A second reader of this socket is therefore a note leak, and a deployment +//! that gives the socket to any uid but Bifrost's has no reader check left. The reads carry no +//! `authority` (a payload that names one is refused whole), and the door has no other way to ask who +//! is reading. +//! //! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is //! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is //! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE` diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs index 4bdec8e..6c5d975 100644 --- a/crates/huginn-mind/src/desk.rs +++ b/crates/huginn-mind/src/desk.rs @@ -125,7 +125,7 @@ pub fn options_hash(question: &PipelineQuestion) -> Sha256Hex { } /// Every desk answer of one fork, with its key. -fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator + 'a { +pub(crate) fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator + 'a { docs.of_kind(PipelineKind::DeskAnswer).filter_map(move |(key, document)| match document { PipelineDocument::DeskAnswer(answer) if answer.fork.0 == fork => Some((key, answer)), _ => None, diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index b327170..0aecca9 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -12,6 +12,8 @@ //! `desk-read-visibility-whole-record`). Nothing is stored. use super::*; +use crate::desk::answers_of; +use crate::desk::options_hash; /// The card document's schema id. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -223,22 +225,135 @@ impl JsonSchema for ForkLimit { } } +/// The card of a question held at the snapshot `docs` reads, shown to the call's `deciders`. +fn card_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { + let open = docs.in_force(PipelineKind::Question, key); + // The ruling that closed the fork: the in-force one that answers it (the key order picks if a second ever does). + let ruling = (!open) + .then(|| { + docs.of_kind(PipelineKind::Ruling) + .filter(|(ruling_key, document)| { + matches!(document, PipelineDocument::Ruling(ruling) if ruling.answers.as_ref().is_some_and(|answers| answers.0 == key)) + && docs.in_force(PipelineKind::Ruling, ruling_key) + }) + .map(|(ruling_key, _)| ruling_key.to_string()) + .min() + }) + .flatten(); + Ok(ForkCard { + schema_version: CardSchema::V0, + fork_id: key.into(), + title: question.title.0.clone(), + context: question.question.0.clone(), + options: question.options.iter().map(|option| ForkOption { id: option.label.0.clone(), text: option.text.0.clone() }).collect(), + recommendation: question.recommended.0.clone(), + depends: question.depends.iter().map(|line| line.0.clone()).collect(), + leans: None, + flag: None, + raised_in: question.raised_in.as_ref().map(|raised| ForkRef { kind: format!("{:?}", raised.kind), id: raised.id.0.clone() }), + deciders: deciders.iter().map(|decider| decider.0.clone()).collect(), + status: if open { CardStatus::Open } else { CardStatus::Closed }, + ruling, + ruled_from: Vec::new(), + order: docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?, + options_hash: options_hash(question).0, + }) +} + +/// The record of a fork: its answers in admission order and the answer that counts at this snapshot. +fn record_of(docs: &Docs<'_>, key: &str, options_hash: &str) -> Result { + let mut stored: Vec<_> = answers_of(docs, key).collect(); + stored.sort_by_key(|(_, answer)| answer.sequence); + let answers = stored.into_iter().map(|(answer_key, answer)| project(answer_key, answer)).collect::, _>>()?; + let counted_answer_id = counted_answer(docs, key).map(|counted| answer_seq(key, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?; + Ok(ForkRecord { schema_version: RecordSchema::V0, fork_id: key.into(), options_hash: options_hash.into(), counted_answer_id, answers }) +} + +fn entry_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { + let card = card_of(docs, key, question, deciders)?; + let record = record_of(docs, key, &card.options_hash)?; + Ok(ForkEntry { schema_version: EntrySchema::V0, card, record }) +} + impl Mind { - /// STUB - pub fn desk_forks(&self, _deciders: &[Short], _status: Option, _cursor: Option, _limit: ForkLimit) -> DeskResponse { - DeskResponse::NotStored(fork_unknown()) + /// One page of the desk's forks, read at one snapshot (`as_of`, also the page's stamp `s`). + /// + /// The showable forks only: a question `showable` refuses (fewer than two options, a blank option + /// or depends line, no decider) is in no count and no page. `open_count` and `recorded_count` cover + /// that whole set whatever `status`, `cursor` and `limit` ask. The page is the forks newest first by + /// `order` (the question's admission ordinal), those with the asked `status` (`None` is both) and + /// an order strictly below the cursor's; `limit` 0 returns no entries. `next_cursor` names the last + /// order returned and is `None` when no further fork matches. Several questions admitted in one + /// commit share an order, and a page that ends between them skips the rest (the contract's cursor + /// has no tie-break). + /// + /// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and + /// feed the showability gate, and gate nothing else. The record is whole, notes included + /// (ruling `desk-read-visibility-whole-record`: Bifrost applies visibility, Huginn strips nothing). + pub fn desk_forks(&self, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> DeskResponse { + self.forks_page(deciders, status, cursor, limit).unwrap_or_else(door_refusal) } - /// STUB - pub fn desk_fork(&self, _deciders: &[Short], _fork_id: &str) -> DeskResponse { - DeskResponse::NotStored(fork_unknown()) + /// One fork, as the entry a page carries, read at one snapshot. A fork that is not held, is not a + /// question or is not showable is `fork_unknown`, the answer path's refusal. Whole record, as `desk_forks`. + pub fn desk_fork(&self, deciders: &[Short], fork_id: &str) -> DeskResponse { + self.fork_entry(deciders, fork_id).unwrap_or_else(door_refusal) + } + + fn forks_page(&self, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> Result { + let image = self.image()?; + let docs = Docs::at(image, image.head); + let mut shown = Vec::new(); + for (key, document) in docs.of_kind(PipelineKind::Question) { + let PipelineDocument::Question(question) = document else { continue }; + if showable(question, deciders) { + let order = docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?; + shown.push((order, key, question, docs.in_force(PipelineKind::Question, key))); + } + } + // Newest first; the key breaks a tie so that a page is the same page on every read. + shown.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(left.1))); + let open_count = shown.iter().filter(|fork| fork.3).count() as u64; + let recorded_count = shown.len() as u64 - open_count; + let mut matching = shown.iter().filter(|(order, _, _, open)| { + cursor.is_none_or(|cursor| *order < cursor.order()) + && match status { + None => true, + Some(ListStatus::Open) => *open, + Some(ListStatus::Recorded) => !*open, + } + }); + let page: Vec<_> = matching.by_ref().take(limit.get() as usize).collect(); + let next_cursor = if matching.next().is_some() { page.last().and_then(|(order, ..)| ForkCursor::new(*order)) } else { None }; + let forks = page.into_iter().map(|(_, key, question, _)| entry_of(&docs, key, question, deciders)).collect::, _>>()?; + let list = ForkList { + schema_version: ListSchema::V0, + mind: self.instance().0.clone(), + stamp: format!("s{}", image.head), + open_count, + recorded_count, + forks, + next_cursor, + }; + Ok(DeskResponse::Forks { list, as_of: image.head }) + } + + fn fork_entry(&self, deciders: &[Short], fork_id: &str) -> Result { + let image = self.image()?; + let docs = Docs::at(image, image.head); + let Some(PipelineDocument::Question(question)) = docs.in_image(PipelineKind::Question, fork_id) else { + return Ok(DeskResponse::NotStored(fork_unknown())); + }; + if !showable(question, deciders) { + return Ok(DeskResponse::NotStored(fork_unknown())); + } + Ok(DeskResponse::Fork { entry: entry_of(&docs, fork_id, question, deciders)?, as_of: image.head }) } } #[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; diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index ec5c99d..f8a7278 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -69,6 +69,11 @@ impl<'i> Docs<'i> { self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| &self.image.row(row).view.document) } + /// The ordinal of the receipt that wrote an image document: its admission order. + pub(crate) fn ordinal_of(&self, kind: PipelineKind, id: &str) -> Option { + self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| self.image.row(row).ordinal) + } + pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) } diff --git a/schemas/cultnet/huginn.desk_request.v1.schema.json b/schemas/cultnet/huginn.desk_request.v1.schema.json index caaa6a6..d250def 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 (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.", + "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\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `subject`\nand `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability\ngate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn\nanswers every read with the whole record and Bifrost applies visibility; see `DeskResponse`\nfor the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.", "oneOf": [ { "type": "object", @@ -52,6 +52,10 @@ "Forks": { "type": "object", "properties": { + "cursor": { + "type": "string", + "pattern": "^c[1-9][0-9]*$" + }, "deciders": { "type": "array", "items": { @@ -61,9 +65,20 @@ "minItems": 1, "uniqueItems": true }, + "limit": { + "$ref": "#/$defs/ForkLimit" + }, "mind": { "$ref": "#/$defs/Slug" }, + "status": { + "description": "Which forks a list asks for; absent is both. `recorded` is a closed card.", + "type": "string", + "enum": [ + "open", + "recorded" + ] + }, "subject": { "$ref": "#/$defs/Short" } @@ -72,7 +87,10 @@ "required": [ "mind", "subject", - "deciders" + "deciders", + "status", + "cursor", + "limit" ] } }, @@ -185,6 +203,11 @@ "request_id" ] }, + "ForkLimit": { + "type": "integer", + "maximum": 50, + "minimum": 0 + }, "RequestSchema": { "description": "The request document's schema id; any other value fails to decode.", "type": "string", diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index 074acc0..87ff74a 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -1,7 +1,7 @@ { "$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.", + "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`Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were\nall read at. `NotStored` is a request that stored nothing (an unknown fork included).\n`Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and\nHuginn writes no document for it.\n\nThe trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`\ncarry the WHOLE record, every answer's note included, to whatever the peer-checked socket\ncarries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a\nstripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable\nonly by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a\nsecond reader of that socket is a note leak.", "oneOf": [ { "type": "object", @@ -35,6 +35,58 @@ "Answered" ] }, + { + "type": "object", + "properties": { + "Forks": { + "type": "object", + "properties": { + "as_of": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "list": { + "$ref": "#/$defs/ForkList" + } + }, + "required": [ + "list", + "as_of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Forks" + ] + }, + { + "type": "object", + "properties": { + "Fork": { + "type": "object", + "properties": { + "as_of": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "entry": { + "$ref": "#/$defs/ForkEntry" + } + }, + "required": [ + "entry", + "as_of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Fork" + ] + }, { "type": "object", "properties": { @@ -68,6 +120,21 @@ "gamecult.desk_answer.v0" ] }, + "CardSchema": { + "description": "The card document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.card.v0" + ] + }, + "CardStatus": { + "description": "Whether a ruling closed the fork.", + "type": "string", + "enum": [ + "open", + "closed" + ] + }, "DeskAuthority": { "type": "string", "enum": [ @@ -76,6 +143,114 @@ "Member" ] }, + "EntrySchema": { + "description": "The entry document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.entry.v0" + ] + }, + "ForkCard": { + "description": "`gamecult.fork.card.v0`: closed, her field order.", + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "deciders": { + "type": "array", + "items": { + "type": "string" + } + }, + "depends": { + "type": "array", + "items": { + "type": "string" + } + }, + "flag": { + "type": "string" + }, + "fork_id": { + "type": "string" + }, + "leans": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkOption" + } + }, + "options_hash": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "raised_in": { + "description": "A typed reference, as the question holds it (`raised_in`).", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "id" + ] + }, + "recommendation": { + "type": "string" + }, + "ruled_from": { + "type": "array", + "items": { + "type": "string" + } + }, + "ruling": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/CardSchema" + }, + "status": { + "$ref": "#/$defs/CardStatus" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "fork_id", + "title", + "context", + "options", + "recommendation", + "depends", + "leans", + "flag", + "raised_in", + "deciders", + "status", + "ruling", + "ruled_from", + "order", + "options_hash" + ] + }, "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", @@ -158,6 +333,121 @@ "request_id" ] }, + "ForkEntry": { + "description": "`gamecult.fork.entry.v0`: a card and its record.", + "type": "object", + "properties": { + "card": { + "$ref": "#/$defs/ForkCard" + }, + "record": { + "$ref": "#/$defs/ForkRecord" + }, + "schemaVersion": { + "$ref": "#/$defs/EntrySchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "card", + "record" + ] + }, + "ForkList": { + "description": "`gamecult.fork.list.v0`: a page of entries, the counts over the showable set and the stamp.", + "type": "object", + "properties": { + "forks": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkEntry" + } + }, + "mind": { + "type": "string" + }, + "next_cursor": { + "type": "string", + "pattern": "^c[1-9][0-9]*$" + }, + "open_count": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "recorded_count": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "schemaVersion": { + "$ref": "#/$defs/ListSchema" + }, + "stamp": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "mind", + "stamp", + "open_count", + "recorded_count", + "forks", + "next_cursor" + ] + }, + "ForkOption": { + "description": "One shown option.", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "text" + ] + }, + "ForkRecord": { + "description": "`gamecult.fork.record.v0`: the answers in admission order and the derived counted answer.", + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkDeskAnswer" + } + }, + "counted_answer_id": { + "type": "string" + }, + "fork_id": { + "type": "string" + }, + "options_hash": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/RecordSchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "fork_id", + "options_hash", + "counted_answer_id", + "answers" + ] + }, "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", @@ -188,6 +478,13 @@ "request_id_conflict" ] }, + "ListSchema": { + "description": "The list document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.list.v0" + ] + }, "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": [ @@ -1497,6 +1794,13 @@ } ] }, + "RecordSchema": { + "description": "The record document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.record.v0" + ] + }, "RefusalSchema": { "description": "The refusal document's schema id.", "type": "string", From e9757d7f45333f81f996ae46ddc31daa8cbf0f3c Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:03:06 +0000 Subject: [PATCH 3/9] Desk reads: page budget and same-order groups, failing tests first (DeskForkTooLarge, DESK_PAGE_BUDGET; the fill is not yet written) Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire.rs | 5 + crates/huginn-mind/src/desk_wire/reads.rs | 177 ++++++++++++++++-- crates/huginn-mind/src/refusal.rs | 5 + .../huginn.desk_response.v1.schema.json | 33 ++++ .../huginn.mind_response.v1.schema.json | 33 ++++ 5 files changed, 240 insertions(+), 13 deletions(-) diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index b5a9da9..65fc77a 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -219,6 +219,11 @@ pub const DESK_RESPONSE_SCHEMA_JSON: &str = include_str!("../../../schemas/cultn /// 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; +/// What a page of forks leaves for the envelope around it: the echoed message id (at most 256 +/// bytes), the operation, the runtime id and the frame's own keys. `Mind::desk_forks` fills a page +/// to `DESK_PAGE_BUDGET`, the response bound less this, measured on the bare message. +pub const DESK_ENVELOPE_MARGIN: usize = 1024; +pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARGIN; /// Most deciders one call carries (CultLib `card.deciders` `maxItems`). pub const DESK_DECIDERS_MAX: usize = 64; diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 0aecca9..29b5123 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -878,22 +878,173 @@ mod tests { shown } - #[test] - fn a_page_is_measured_against_the_response_bound_and_the_bound_is_the_door_s() { + fn encoded(message_id: &str, runtime: &str, response: &DeskResponse) -> usize { + let message = encode_desk_response(message_id, "forks", response, runtime).unwrap(); + cultnet_rs::encode_cultnet_message_to_vec(&message, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap().len() + } + + /// The reply as the door sends it: the longest message id it echoes, the daemon's runtime id. + fn on_the_wire(response: &DeskResponse) -> usize { + encoded(&"m".repeat(256), &format!("huginn-{INSTANCE}"), response) + } + + /// The reply as the page fill measures it: the message with one-byte ids. + fn bare(response: &DeskResponse) -> usize { + encoded("m", "r", response) + } + + /// Every page from the newest fork on, following the cursors, with the response each came in. + fn pages(mind: &Mind, size: u8) -> Vec<(DeskResponse, ForkList)> { + let (mut cursor, mut out) = (None, Vec::new()); + loop { + let response = mind.desk_forks(&list(&LIST), None, cursor, limit(size)); + let (page, _) = page(response.clone()); + cursor = page.next_cursor; + out.push((response, page)); + assert!(out.len() < 200, "the cursors do not advance"); + if cursor.is_none() { + return out; + } + } + } + + fn admitted_each(range: std::ops::RangeInclusive) -> Mind { let mut mind = seeded(); - for n in 1..=50 { + for n in range { 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() + mind + } + + #[test] + fn a_page_stops_at_the_response_budget_and_the_pages_are_every_fork_once_in_order() { + let mind = admitted_each(1..=50); + let all = pages(&mind, 50); + assert!(all.len() > 1, "fifty wordy cards do not fit one page"); + let seen: Vec = all.iter().flat_map(|(_, page)| ids(page)).collect(); + assert_eq!(seen, (1..=50).rev().map(fork).collect::>(), "every fork once, newest first"); + let first = &all[0].1; + for (response, page) in &all { + assert!(on_the_wire(response) <= DESK_MAX_RESPONSE_BYTES, "{} bytes", on_the_wire(response)); + assert!(bare(response) <= DESK_PAGE_BUDGET); + assert!(!page.forks.is_empty() && page.forks.len() < 50, "short of the limit by bytes"); + assert_eq!((page.open_count, page.recorded_count, &page.stamp), (50, 0, &first.stamp), "counts and stamp are the whole desk's"); + } + assert!(all[..all.len() - 1].iter().all(|(_, page)| page.next_cursor.is_some()) && all.last().unwrap().1.next_cursor.is_none()); + } + + /// The last entry of the page moves to the next page when one more byte of its text would take + /// the page past the budget, and not a byte sooner. + #[test] + fn the_page_budget_is_exact() { + let build = |forks: u32, text: usize| { + let mut mind = seeded(); + let mut oldest = wordy(1); + let D::Question(held) = &mut oldest else { unreachable!() }; + held.question = "z".repeat(text).as_str().into(); + committed(admit(&mut mind, vec![oldest])); + for n in 2..=forks { + committed(admit(&mut mind, vec![wordy(n)])); + } + mind }; - 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)"); + let first_page = |mind: &Mind| page(mind.desk_forks(&list(&LIST), None, None, limit(50))).0; + // The most forks that fit one page with the oldest one's text short. + let fits = (2..=50).take_while(|forks| first_page(&build(*forks, 300)).forks.len() == *forks as usize).last().expect("two fit"); + assert!(fits < 50, "the desk outgrows a page"); + // Grow the oldest fork's text one byte at a time; the page holds it until the budget is exact. + let (mut low, mut high) = (300_usize, 6000_usize); + while high - low > 1 { + let mid = (low + high) / 2; + if first_page(&build(fits, mid)).forks.len() == fits as usize { low = mid } else { high = mid } + } + let (in_page, over) = (build(fits, low), build(fits, low + 1)); + let held = page(in_page.desk_forks(&list(&LIST), None, None, limit(50))); + assert_eq!((held.0.forks.len(), held.0.next_cursor), (fits as usize, None)); + assert_eq!(bare(&DeskResponse::Forks { list: held.0, as_of: held.1 }), DESK_PAGE_BUDGET, "the last byte that fits"); + let (moved, _) = page(over.desk_forks(&list(&LIST), None, None, limit(50))); + assert_eq!(moved.forks.len(), fits as usize - 1, "one byte more and the oldest fork goes to the next page"); + assert_eq!(moved.next_cursor.map(ForkCursor::order), moved.forks.last().map(|entry| entry.card.order)); + let (rest, _) = page(over.desk_forks(&list(&LIST), None, moved.next_cursor, limit(50))); + assert_eq!(ids(&rest), [fork(1)]); + } + + /// A fork whose entry alone is past the budget cannot be paged: it is refused by name, never dropped. + #[test] + fn a_lone_oversize_entry_is_refused_by_name() { + let mut mind = desk(3); + let hash = hash_of(&mind, &fork(2)); + let note = "a long reason, ".repeat(60); + let mut grown = 0; + while bare(&mind.desk_fork(&list(&LIST), &fork(2))) <= DESK_PAGE_BUDGET { + let DeskResponse::Answered { .. } = answer(&mut mind, "x", &LIST, &fork(2), &hash, Some(¬e)) else { panic!("the note was not stored") }; + grown += 1; + assert!(grown < 200, "the record does not grow"); + } + // The newest fork pages; the one with the long record is refused when its page is asked for. + let response = mind.desk_forks(&list(&LIST), None, ForkCursor::new(docs_order(&mind, &fork(3))), limit(50)); + let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, bytes, limit: bound }) = response else { panic!("{response:?}") }; + assert_eq!((named, bound), (fork(2), DESK_PAGE_BUDGET as u64)); + assert!(bytes > bound); + // And a read of that fork alone is still its whole entry. + entry(mind.desk_fork(&list(&LIST), &fork(2))); + } + + fn docs_order(mind: &Mind, key: &str) -> u64 { + let image = mind.image().unwrap(); + Docs::at(image, image.head).ordinal_of(PipelineKind::Question, key).unwrap() + } + + /// Questions admitted in one commit share an order, and the cursor is "strictly below an order": + /// a page never ends inside such a group (the page may pass `limit` by the group's size). + #[test] + fn a_page_never_splits_a_group_admitted_in_one_commit() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question_n(1)])); + committed(admit(&mut mind, (2..=6).map(question_n).collect())); + committed(admit(&mut mind, vec![question_n(7)])); + // Newest first; the key breaks a tie inside the group. + let mut group: Vec = (2..=6).map(fork).collect(); + group.sort(); + group.reverse(); + let expected: Vec = std::iter::once(fork(7)).chain(group).chain(std::iter::once(fork(1))).collect(); + for size in [1, 2, 3, 5, 6] { + let all = pages(&mind, size); + let seen: Vec = all.iter().flat_map(|(_, page)| ids(page)).collect(); + assert_eq!(seen, expected, "limit {size}: no fork skipped or repeated"); + for (_, page) in &all { + let orders: Vec = page.forks.iter().map(|entry| entry.card.order).collect(); + let group = orders.iter().filter(|order| **order == docs_order(&mind, &fork(2))).count(); + assert!(group == 0 || group == 5, "limit {size}: the group of five is whole or absent, not {group}"); + } + } + // The first page at limit 2 runs past the limit to finish the group it ends in. + let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(2))); + assert_eq!(first.forks.len(), 6); + assert_eq!(first.next_cursor.map(ForkCursor::order), Some(docs_order(&mind, &fork(2)))); + } + + /// A group the page budget would end inside goes whole to the next page; one that is over the + /// budget by itself is refused by name of its first fork. + #[test] + fn a_group_past_the_byte_budget_moves_whole_or_is_refused() { + let mut mind = seeded(); + committed(admit(&mut mind, (1..=50).map(wordy).collect())); + let response = mind.desk_forks(&list(&LIST), None, None, limit(50)); + let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, .. }) = response else { panic!("{response:?}") }; + assert!(named.starts_with("eureka-state:question:Q")); + let mut mind = seeded(); + committed(admit(&mut mind, vec![wordy(1)])); + committed(admit(&mut mind, (2..=30).map(wordy).collect())); + committed(admit(&mut mind, vec![wordy(31)])); + let all = pages(&mind, 50); + let sizes: Vec = all.iter().map(|(_, page)| page.forks.len()).collect(); + assert!(sizes.len() > 1, "{sizes:?}"); + assert_eq!(sizes.iter().sum::(), 31); + for (response, page) in &all { + assert!(on_the_wire(response) <= DESK_MAX_RESPONSE_BYTES); + let group = page.forks.iter().filter(|entry| entry.card.order == docs_order(&mind, &fork(2))).count(); + assert!(group == 0 || group == 29, "the group of 29 is whole or absent, not {group}"); + } } } diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 06dfb10..5987d04 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -109,6 +109,11 @@ pub enum MindRefusal { /// truncated or paginated on its behalf. The daemon owns it and its /// deferral bound decides it; no rule of the mind is involved. ResponseTooLarge { bytes: u64, limit: u64 }, + /// A desk page that cannot hold even its first entry (or the whole group of forks admitted + /// in one commit that the cursor will not split): `fork` is the first fork of that group, `bytes` + /// what the page holding it would encode to (without the envelope) and `limit` the page budget + /// it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`. + DeskForkTooLarge { fork: String, bytes: u64, limit: u64 }, /// A selection the substrate or the organ's value door refused: `field` /// is the selection field the client sent, `value` the offending part. SelectionInvalid { field: String, value: Option, message: String }, diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index 87ff74a..0c1555a 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -1561,6 +1561,39 @@ "ResponseTooLarge" ] }, + { + "description": "A desk page that cannot hold even its first entry (or the whole group of forks admitted\nin one commit that the cursor will not split): `fork` is the first fork of that group, `bytes`\nwhat the page holding it would encode to (without the envelope) and `limit` the page budget\nit exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", + "type": "object", + "properties": { + "DeskForkTooLarge": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "fork": { + "type": "string" + }, + "limit": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "fork", + "bytes", + "limit" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskForkTooLarge" + ] + }, { "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", diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index c3d6e4e..83a6257 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -2059,6 +2059,39 @@ "ResponseTooLarge" ] }, + { + "description": "A desk page that cannot hold even its first entry (or the whole group of forks admitted\nin one commit that the cursor will not split): `fork` is the first fork of that group, `bytes`\nwhat the page holding it would encode to (without the envelope) and `limit` the page budget\nit exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", + "type": "object", + "properties": { + "DeskForkTooLarge": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "fork": { + "type": "string" + }, + "limit": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "fork", + "bytes", + "limit" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskForkTooLarge" + ] + }, { "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", From 31cc69922e248a19b2cc5274a79fc105fb36d79a Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:05:08 +0000 Subject: [PATCH 4/9] Desk reads: a page fills to the response budget and never splits a same-order group Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire/reads.rs | 113 ++++++++++++++++------ 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 29b5123..8fbefca 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -269,6 +269,22 @@ fn record_of(docs: &Docs<'_>, key: &str, options_hash: &str) -> Result Result { + let unavailable = |error: &dyn std::fmt::Display| MindRefusal::Unavailable { detail: format!("a desk page cannot be measured: {error}") }; + let message = crate::envelope::encode_desk_response("m", "forks", response, "r").map_err(|error| unavailable(&error))?; + cultnet_rs::encode_cultnet_message_to_vec(&message, cultnet_rs::CultNetWireContract::CultNetSchemaV0).map(|bytes| bytes.len()).map_err(|error| unavailable(&error)) +} + +/// The page a response under construction carries. +fn held(response: &mut DeskResponse) -> &mut ForkList { + match response { + DeskResponse::Forks { list, .. } => list, + _ => unreachable!("a page is built as a page"), + } +} + fn entry_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { let card = card_of(docs, key, question, deciders)?; let record = record_of(docs, key, &card.options_hash)?; @@ -283,9 +299,17 @@ impl Mind { /// that whole set whatever `status`, `cursor` and `limit` ask. The page is the forks newest first by /// `order` (the question's admission ordinal), those with the asked `status` (`None` is both) and /// an order strictly below the cursor's; `limit` 0 returns no entries. `next_cursor` names the last - /// order returned and is `None` when no further fork matches. Several questions admitted in one - /// commit share an order, and a page that ends between them skips the rest (the contract's cursor - /// has no tie-break). + /// order returned and is `None` when no further fork matches. + /// + /// A page is filled until `limit` entries are held or the next entry would push the encoded + /// response past `DESK_PAGE_BUDGET` (the one `DESK_MAX_RESPONSE_BYTES` less the envelope margin), so + /// it may hold fewer than `limit`; counts and stamp are unaffected. Questions admitted in one commit + /// share an `order`, and the contract's cursor ("an order strictly below N") has no tie-break, so + /// this is Huginn's rule pending a CultLib contract note: a page never splits such a group. When + /// `limit` or the budget would end the page inside a group the whole group is taken (the page may + /// pass `limit` by the group's size), or, when the budget forbids that, the whole group is left for + /// the next page. A page that cannot hold its first group is refused as + /// `MindRefusal::DeskForkTooLarge`, naming that group's first fork; nothing is dropped silently. /// /// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and /// feed the showability gate, and gate nothing else. The record is whole, notes included @@ -315,27 +339,52 @@ impl Mind { shown.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(left.1))); let open_count = shown.iter().filter(|fork| fork.3).count() as u64; let recorded_count = shown.len() as u64 - open_count; - let mut matching = shown.iter().filter(|(order, _, _, open)| { - cursor.is_none_or(|cursor| *order < cursor.order()) - && match status { - None => true, - Some(ListStatus::Open) => *open, - Some(ListStatus::Recorded) => !*open, - } - }); - let page: Vec<_> = matching.by_ref().take(limit.get() as usize).collect(); - let next_cursor = if matching.next().is_some() { page.last().and_then(|(order, ..)| ForkCursor::new(*order)) } else { None }; - let forks = page.into_iter().map(|(_, key, question, _)| entry_of(&docs, key, question, deciders)).collect::, _>>()?; - let list = ForkList { - schema_version: ListSchema::V0, - mind: self.instance().0.clone(), - stamp: format!("s{}", image.head), - open_count, - recorded_count, - forks, - next_cursor, + let matching: Vec<_> = shown + .iter() + .filter(|(order, _, _, open)| { + cursor.is_none_or(|cursor| *order < cursor.order()) + && match status { + None => true, + Some(ListStatus::Open) => *open, + Some(ListStatus::Recorded) => !*open, + } + }) + .collect(); + let mut response = DeskResponse::Forks { + list: ForkList { + schema_version: ListSchema::V0, + mind: self.instance().0.clone(), + stamp: format!("s{}", image.head), + open_count, + recorded_count, + forks: Vec::new(), + next_cursor: None, + }, + as_of: image.head, }; - Ok(DeskResponse::Forks { list, as_of: image.head }) + let (mut taken, mut last_order) = (0, None); + while taken < matching.len() && held(&mut response).forks.len() < limit.get() as usize { + // A group admitted in one commit shares an order and goes whole (see the doc comment). + let order = matching[taken].0; + let end = taken + matching[taken..].iter().take_while(|fork| fork.0 == order).count(); + let kept = held(&mut response).forks.len(); + for (_, key, question, _) in &matching[taken..end] { + held(&mut response).forks.push(entry_of(&docs, key, question, deciders)?); + } + held(&mut response).next_cursor = if end < matching.len() { ForkCursor::new(order) } else { None }; + let bytes = page_bytes(&response)?; + let list = held(&mut response); + if bytes > DESK_PAGE_BUDGET { + if kept == 0 { + return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: matching[taken].1.to_string(), bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 })); + } + list.forks.truncate(kept); + list.next_cursor = last_order.and_then(ForkCursor::new); + break; + } + (taken, last_order) = (end, Some(order)); + } + Ok(response) } fn fork_entry(&self, deciders: &[Short], fork_id: &str) -> Result { @@ -961,9 +1010,14 @@ mod tests { let (in_page, over) = (build(fits, low), build(fits, low + 1)); let held = page(in_page.desk_forks(&list(&LIST), None, None, limit(50))); assert_eq!((held.0.forks.len(), held.0.next_cursor), (fits as usize, None)); - assert_eq!(bare(&DeskResponse::Forks { list: held.0, as_of: held.1 }), DESK_PAGE_BUDGET, "the last byte that fits"); + let size = bare(&DeskResponse::Forks { list: held.0, as_of: held.1 }); + // The wire grows in steps of up to three bytes per text byte, so "exact" is within one step. + assert!(size <= DESK_PAGE_BUDGET && DESK_PAGE_BUDGET - size < 4, "{size} against {DESK_PAGE_BUDGET}"); let (moved, _) = page(over.desk_forks(&list(&LIST), None, None, limit(50))); assert_eq!(moved.forks.len(), fits as usize - 1, "one byte more and the oldest fork goes to the next page"); + // The page it would have been, composed from the single reads, is past the budget. + let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() }; + assert!(bare(&DeskResponse::Forks { list: would_be, as_of: head(&over) }) > DESK_PAGE_BUDGET); assert_eq!(moved.next_cursor.map(ForkCursor::order), moved.forks.last().map(|entry| entry.card.order)); let (rest, _) = page(over.desk_forks(&list(&LIST), None, moved.next_cursor, limit(50))); assert_eq!(ids(&rest), [fork(1)]); @@ -1035,16 +1089,17 @@ mod tests { assert!(named.starts_with("eureka-state:question:Q")); let mut mind = seeded(); committed(admit(&mut mind, vec![wordy(1)])); - committed(admit(&mut mind, (2..=30).map(wordy).collect())); - committed(admit(&mut mind, vec![wordy(31)])); + committed(admit(&mut mind, (2..=21).map(wordy).collect())); + for n in 22..=29 { + committed(admit(&mut mind, vec![wordy(n)])); + } let all = pages(&mind, 50); let sizes: Vec = all.iter().map(|(_, page)| page.forks.len()).collect(); - assert!(sizes.len() > 1, "{sizes:?}"); - assert_eq!(sizes.iter().sum::(), 31); + assert_eq!(sizes, [8, 21], "the eight newest fill the first page; the group of twenty does not fit beside them and moves whole"); for (response, page) in &all { assert!(on_the_wire(response) <= DESK_MAX_RESPONSE_BYTES); let group = page.forks.iter().filter(|entry| entry.card.order == docs_order(&mind, &fork(2))).count(); - assert!(group == 0 || group == 29, "the group of 29 is whole or absent, not {group}"); + assert!(group == 0 || group == 20, "the group of 20 is whole or absent, not {group}"); } } } From 3b775a23c8697def324154fba5662c3abb32ce39 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:06:27 +0000 Subject: [PATCH 5/9] Desk door tests: a peer that never reads fails the suite by assertion in five seconds, not by hanging it Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/desk_door_tests.rs | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index b8b48f6..4eabdca 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -992,17 +992,26 @@ mod bounds { fn a_peer_that_never_reads_is_cut_off_at_one_second() { let (_peer, server) = UnixStream::pair().unwrap(); let mut stream = Deadlined::new(server, DESK_CONNECTION_DEADLINE); - let (chunk, started) = (vec![0_u8; 64 * 1024], Instant::now()); - let error = loop { - if let Err(error) = stream.write(&chunk) { - break error; - } - }; - let took = started.elapsed(); + // The writer runs on its own thread and the test waits for it with a clock of its own: without + // the deadline the write blocks for good, and that must fail this test, not hang the suite. + let (sent, heard) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + let (chunk, started) = (vec![0_u8; 64 * 1024], Instant::now()); + let error = loop { + if let Err(error) = stream.write(&chunk) { + break error; + } + }; + let took = started.elapsed(); + let again = stream.write(&chunk).unwrap_err().kind(); + let _ = sent.send((error, took, again)); + }); + let (error, took, again) = heard.recv_timeout(Duration::from_secs(5)).expect("a peer that never reads is cut off within seconds, not waited on"); + writer.join().unwrap(); // The kernel's send timeout reads as WouldBlock; the deadline already spent reads as TimedOut. assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "{error:?}"); assert!(took >= Duration::from_millis(950) && took < Duration::from_millis(1250), "the write was cut after {took:?}"); - assert_eq!(stream.write(&chunk).unwrap_err().kind(), std::io::ErrorKind::TimedOut, "once spent, nothing more is written"); + assert_eq!(again, std::io::ErrorKind::TimedOut, "once spent, nothing more is written"); } /// A catalog is a reply like any other and is measured against the response bound: one that From aeeb36ad2a7cba5be0edd4240a2700cbb37f3751 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:06:27 +0000 Subject: [PATCH 6/9] Desk answer: delete the replayed flag nobody read; tests assert that a replay commits nothing Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/daemon.rs | 2 +- crates/huginn-mind/src/desk_wire.rs | 97 ++++++++++++----------- crates/huginn-mind/src/desk_wire/reads.rs | 4 +- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index f66a9e5..79c38e5 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -243,7 +243,7 @@ impl> Daemon { } match request { DeskRequest::Answer { subject, authority, deciders, request, .. } => { - self.mind.desk_answer(&deciders, subject, authority, request, now).0 + self.mind.desk_answer(&deciders, subject, authority, request, now) } 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-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 65fc77a..3c2c956 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -483,9 +483,8 @@ fn showable(question: &PipelineQuestion, deciders: &[Short]) -> bool { 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 `authority` are the verified + /// decided and stored (or committed) by `answer_desk`, and the stored answer projected with the + /// derived count at the head it was read at. `subject` and `authority` are the verified /// caller's, `deciders` the door's list. pub fn desk_answer( &mut self, @@ -494,10 +493,10 @@ impl Mind { authority: DeskAuthority, request: ForkAnswerRequest, now: DateTime, - ) -> (DeskResponse, bool) { + ) -> DeskResponse { match self.desk_response(deciders, subject, authority, &request, now) { Ok(done) => done, - Err(refused) => (door_refusal(refused), false), + Err(refused) => door_refusal(refused), } } @@ -508,10 +507,10 @@ impl Mind { authority: DeskAuthority, request: &ForkAnswerRequest, now: DateTime, - ) -> Result<(DeskResponse, bool), MindRefusal> { + ) -> Result { let sent = match request_of(request) { Ok(sent) => sent, - Err(refused) => return Ok((DeskResponse::NotStored(refused), false)), + Err(refused) => return Ok(DeskResponse::NotStored(refused)), }; let image = self.image()?; let docs = Docs::at(image, image.head); @@ -520,12 +519,11 @@ impl Mind { let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none() && matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders)); if unshowable { - return Ok((DeskResponse::NotStored(fork_unknown()), false)); + return Ok(DeskResponse::NotStored(fork_unknown())); } 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), + let key = match self.answer_desk(stamp, sent, now) { + DeskAnswerOutcome::Stored { answer, .. } | DeskAnswerOutcome::Replayed { answer, .. } => answer.0, DeskAnswerOutcome::Refused(refused) => return Err(refused), }; let image = self.image()?; @@ -533,7 +531,7 @@ impl Mind { let Some(PipelineDocument::DeskAnswer(stored)) = docs.find(PipelineKind::DeskAnswer, &key) else { return Err(unreadable_key()) }; let fork = stored.fork.0.as_str(); let counted_answer_id = counted_answer(&docs, fork).map(|counted| answer_seq(fork, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?; - Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head }, replayed)) + Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head })) } } @@ -621,8 +619,12 @@ mod tests { list.iter().map(|who| s(who)).collect() } + /// The response and whether the call committed anything: a replay, a refused body and a gate + /// refusal store nothing, so the head does not move. fn ask_as(mind: &mut Mind, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) { - mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now()) + let before = head(mind); + let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now()); + (response, head(mind) != before) } fn ask(mind: &mut Mind, who: &str, request: ForkAnswerRequest) -> (DeskResponse, bool) { @@ -883,9 +885,9 @@ mod tests { assert_eq!((answer.outcome.as_str(), answer.retracts.as_deref(), counted.as_deref(), as_of), ("Retraction", Some("n1"), Some("n2"), head(&mind))); // The first request replayed: its stored word is still Counted, the count is read now. let before = head(&mind); - let (response, replayed) = ask(&mut mind, "x", first); + let (response, committed) = ask(&mut mind, "x", first); let (answer, counted, as_of) = answered(response); - assert!(replayed); + assert!(!committed); assert_eq!((answer.outcome.as_str(), answer.answer_id.as_str(), counted.as_deref(), as_of), ("Counted", "n1", Some("n2"), before)); assert_eq!(head(&mind), before); // The second request replayed now counts, and its stored word is still Revision. @@ -1021,14 +1023,14 @@ mod tests { ]; for (form, request) in forms.into_iter().enumerate() { let stored = stored_count(&mind, FORK); - let (response, replayed) = ask(&mut mind, "x", request.clone()); - assert!(!replayed); + let (response, committed) = ask(&mut mind, "x", request.clone()); + assert!(committed); let (answer, _, _) = answered(response.clone()); assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::BadRetraction)), "form {form}"); assert_eq!(stored_count(&mind, FORK), stored + 1, "form {form}"); // The refusal is stored and bound: the same bytes replay, with no write. let before = head(&mind); - assert_eq!(ask(&mut mind, "x", request), (response, true), "form {form}"); + assert_eq!(ask(&mut mind, "x", request), (response, false), "form {form}"); assert_eq!(head(&mind), before); } // (i) and (iii) with equal answer and target differ only in `retracted`: one id, two bodies. @@ -1154,14 +1156,14 @@ mod tests { fn a_stale_answer_carries_no_card() { let mut mind = desk(); let stale = wire(FORK, &"e".repeat(64)); - let (fresh, replayed) = ask(&mut mind, "x", stale.clone()); - assert!(!replayed); + let (fresh, committed) = ask(&mut mind, "x", stale.clone()); + assert!(committed); let (answer, _, _) = answered(fresh.clone()); assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::StaleFork))); // After the fork closed, the retry still answers the stored refusal, with nothing added. close(&mut mind, FORK, "RQ1", "A"); - let (again, replayed) = ask(&mut mind, "x", stale); - assert!(replayed); + let (again, committed) = ask(&mut mind, "x", stale); + assert!(!committed); let (again_answer, _, _) = answered(again.clone()); assert_eq!(again_answer, answer); for response in [&fresh, &again] { @@ -1176,7 +1178,7 @@ mod tests { let request = wire(FORK, &hash); let (fresh, first) = ask(&mut mind, "x", request.clone()); let (replay, second) = ask(&mut mind, "x", request); - assert_eq!((first, second), (false, true)); + assert_eq!((first, second), (true, false)); assert_eq!(encode(&fresh), encode(&replay)); assert_eq!(stored_count(&mind, FORK), 1); } @@ -1225,17 +1227,17 @@ mod tests { let mut mind = desk(); let hash = hash_of(&mind, FORK); let request = wire(FORK, &hash); - let (stored, replayed) = ask(&mut mind, "x", request.clone()); - assert!(!replayed); + let (stored, committed) = ask(&mut mind, "x", request.clone()); + assert!(committed); let before = head(&mind); // The list Bifrost passes is empty now; the stored request still gets its stored answer. - let (again, replayed) = ask_as(&mut mind, "x", &[], request.clone()); - assert!(replayed); + let (again, committed) = ask_as(&mut mind, "x", &[], request.clone()); + assert!(!committed); assert_eq!(encode(&again), encode(&stored)); assert_eq!(head(&mind), before); // Another subject with the same bytes has stored nothing: the gate refuses it. - let (other, replayed) = ask_as(&mut mind, "y", &[], request); - assert_eq!((not_stored(other).code, replayed), (ForkRefusalCode::ForkUnknown, false)); + let (other, committed) = ask_as(&mut mind, "y", &[], request); + assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false)); // And a request nobody stored is fork_unknown while the list is empty. let fresh = wire(FORK, &hash); assert_eq!(not_stored(ask_as(&mut mind, "x", &[], fresh).0).code, ForkRefusalCode::ForkUnknown); @@ -1243,22 +1245,22 @@ mod tests { // A stored refusal and a stored revision replay the same way: a stale hash is stored as a // refusal, a second answer by the same subject as a revision. let stale = wire(FORK, "0".repeat(hash.len()).as_str()); - let (refused, replayed) = ask(&mut mind, "x", stale.clone()); - assert!(!replayed); + let (refused, committed) = ask(&mut mind, "x", stale.clone()); + assert!(committed); assert_eq!(answered(refused.clone()).0.outcome, "Refused"); let revision = wire(FORK, &hash); - let (revised, replayed) = ask(&mut mind, "x", revision.clone()); - assert!(!replayed); + let (revised, committed) = ask(&mut mind, "x", revision.clone()); + assert!(committed); assert_eq!(answered(revised.clone()).0.outcome, "Revision"); let before = head(&mind); for (request, stored) in [(stale, refused), (revision, revised)] { - let (again, replayed) = ask_as(&mut mind, "x", &[], request.clone()); - assert!(replayed); + let (again, committed) = ask_as(&mut mind, "x", &[], request.clone()); + assert!(!committed); // `as_of` is the head the replay was read at, so the stored answer is what is compared. assert_eq!(encode(&answered(again).0), encode(&answered(stored).0)); assert_eq!(head(&mind), before); - let (other, replayed) = ask_as(&mut mind, "w", &[], request); - assert_eq!((not_stored(other).code, replayed), (ForkRefusalCode::ForkUnknown, false)); + let (other, committed) = ask_as(&mut mind, "w", &[], request); + assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false)); assert_eq!(head(&mind), before); } } @@ -1322,16 +1324,16 @@ mod tests { let (first, _) = ask(&mut mind, "x", request.clone()); // V12: the same bytes replay. let before = head(&mind); - assert_eq!(ask(&mut mind, "x", request.clone()), (first, true)); + assert_eq!(ask(&mut mind, "x", request.clone()), (first, false)); assert_eq!(head(&mind), before); // V13: only answered_at changed is a conflict, and nothing is stored. let moved = ForkAnswerRequest { answered_at: "2026-11-10T12:00:01Z".into(), ..request.clone() }; assert_eq!(not_stored(ask(&mut mind, "x", moved).0).code, ForkRefusalCode::RequestIdConflict); assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1)); // V14: another subject with identical bytes is its own request: its own answer id, stamped with it. - let (other, replayed) = ask(&mut mind, "y", request.clone()); + let (other, committed) = ask(&mut mind, "y", request.clone()); let (other, _, _) = answered(other); - assert!(!replayed); + assert!(committed); assert_eq!((other.answer_id.as_str(), other.answerer.as_str(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str())); let (original, _, _) = answered(ask(&mut mind, "x", request.clone()).0); assert_eq!((original.answer_id.as_str(), original.answerer.as_str()), ("n1", "x")); @@ -1489,7 +1491,7 @@ mod tests { let authority = serde_json::from_value(server["authority"].clone()).unwrap(); let at = DateTime::parse_from_rfc3339(server["received_at"].as_str().unwrap()).unwrap().to_utc(); let answerer = s(server["answerer"].as_str().unwrap()); - let (mine, _) = mind.desk_answer(&deciders(&list), answerer, authority, request, at); + let mine = mind.desk_answer(&deciders(&list), answerer, authority, request, at); if code == Some("unavailable") { continue; // the door went down after the Mind stored: its answer was never seen } @@ -1545,9 +1547,9 @@ mod tests { 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. - let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now()).0; + let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now()); assert_eq!(answered(off).0.code, Some(ForkAnswerCode::NotADecider)); - let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now()).0; + let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now()); let (answer, _, _) = answered(on); assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_str()), ("Counted", DeskAuthority::Member, "m")); } @@ -1587,8 +1589,9 @@ mod tests { } // 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); + let before = head(&mind); + let response = mind.desk_answer(&deciders, subject, authority, request, now()); + assert!(head(&mind) > before); assert_eq!(answered(response).0.outcome, "Counted"); let (head_before, stored_before) = (head(&mind), stored_count(&mind, FORK)); @@ -1622,7 +1625,7 @@ mod tests { 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); + let refused = not_stored(mind.desk_answer(&[], s("d0"), DeskAuthority::Member, wire(FORK, &hash), now())); assert_eq!(refused.code, ForkRefusalCode::ForkUnknown); assert_eq!((head(&mind), stored_count(&mind, FORK)), before); } @@ -1643,6 +1646,6 @@ mod tests { // 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"); + assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now())).0.outcome, "Counted"); } } diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 8fbefca..c17a647 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -461,7 +461,7 @@ mod tests { answered_at: "2026-09-16T11:00:00Z".into(), request_id: request_id(), }; - mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now()).0 + mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now()) } fn close(mind: &mut Mind, fork: &str, label: &str) { @@ -755,7 +755,7 @@ mod tests { 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; + let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now()); 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")); From 071dc8e345f00010ac6cbb262ec208ac405a99a7 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:09:10 +0000 Subject: [PATCH 7/9] Desk door test: the page is short of the bound by the Mind's fill; a group past the budget is the Mind's refusal Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/desk_door_tests.rs | 30 +++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index 4eabdca..1403199 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -902,10 +902,12 @@ mod door_smoke { 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. + /// Fifty wordy forks, one commit each. A limit-50 page of them would pass the response bound: the Mind + /// stops the page short at its budget and names the cursor, so the door sends it whole; the door's own + /// measure stays exact at the bound. A group of them admitted in one commit that is past the budget by + /// itself comes back as the Mind's refusal naming its first fork. #[test] - fn a_page_past_the_response_bound_is_refused_by_name_and_never_cut() { + fn a_page_is_short_of_the_response_bound_and_the_door_still_measures_it_exactly() { let mut rig = rig(my_uid()); let (question, _) = question_and_ruling(); let PipelineDocument::Question(held) = question else { unreachable!() }; @@ -917,15 +919,27 @@ mod door_smoke { PipelineDocument::Question(wordy) }) .collect(); - let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), now()).answered(); + let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents.clone())), 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); + let grouped = rig.serving(|path| reply_of(path, "m-0", &forks_all(&["x"]))); + let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { bytes, limit, .. }) = grouped else { panic!("{grouped:?}") }; + assert_eq!(limit, huginn_mind::desk_wire::DESK_PAGE_BUDGET as u64); assert!(bytes > limit); + assert_eq!(rig.state(), before); + // Then fifty more, each in its own commit, newer than the group. + for n in 52..=101 { + let PipelineDocument::Question(mut wordy) = documents[0].clone() else { unreachable!() }; + wordy.label = format!("Q{n}").as_str().into(); + let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![PipelineDocument::Question(wordy)])), 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 (big, _) = page_of(big); + assert!(big.forks.len() > 10 && big.forks.len() < 50 && big.next_cursor.is_some(), "{} entries", big.forks.len()); let (small, _) = page_of(small); - assert_eq!((small.forks.len(), small.open_count), (10, 51)); + assert_eq!((small.forks.len(), small.open_count), (10, 101)); 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. From 288d17cad3fc048af86e56c1b0ca043a4707b85b Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:31:57 +0000 Subject: [PATCH 8/9] Desk reads: order is a unique 0-based rank; failing tests first (batch of 64 pages, rank stability, gaps, lone oversize, vectors replay); DeskForkTooLarge names the order Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/desk_wire/reads.rs | 366 ++++++++++++++---- crates/huginn-mind/src/refusal.rs | 10 +- .../huginn.desk_response.v1.schema.json | 8 +- .../huginn.mind_response.v1.schema.json | 8 +- 4 files changed, 317 insertions(+), 75 deletions(-) diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index c17a647..44431a3 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -376,7 +376,7 @@ impl Mind { let list = held(&mut response); if bytes > DESK_PAGE_BUDGET { if kept == 0 { - return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: matching[taken].1.to_string(), bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 })); + return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: matching[taken].1.to_string(), order: matching[taken].0, bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 })); } list.forks.truncate(kept); list.next_cursor = last_order.and_then(ForkCursor::new); @@ -406,7 +406,7 @@ mod tests { 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 crate::{Faculty, PipelineAdmissionBatch, PipelineAdmissionOutcome}; use eureka_pipeline::{Line, PipelineRef, QuestionOption}; use serde_json::{Value, json}; @@ -560,7 +560,9 @@ mod tests { 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)] { + assert_eq!(orders, [3, 2, 1, 0], "the ranks, from 0"); + assert_eq!(ForkCursor::new(0), None, "no cursor names rank 0: nothing is below it"); + for (at, expected) in [(0, 3), (1, 2), (2, 1)] { 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]); @@ -719,7 +721,7 @@ mod tests { 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!(card.order, 0, "the only question is rank 0"); assert_eq!(read.record.fork_id, fork(1)); assert_eq!((read.record.counted_answer_id, read.record.answers.len()), (None, 0)); } @@ -1023,25 +1025,16 @@ mod tests { assert_eq!(ids(&rest), [fork(1)]); } - /// A fork whose entry alone is past the budget cannot be paged: it is refused by name, never dropped. - #[test] - fn a_lone_oversize_entry_is_refused_by_name() { - let mut mind = desk(3); - let hash = hash_of(&mind, &fork(2)); + /// Grows fork `n`'s record with notes until its entry alone encodes to more than `bytes`. + fn grown_past(mind: &mut Mind, n: u32, bytes: usize) { + let hash = hash_of(mind, &fork(n)); let note = "a long reason, ".repeat(60); - let mut grown = 0; - while bare(&mind.desk_fork(&list(&LIST), &fork(2))) <= DESK_PAGE_BUDGET { - let DeskResponse::Answered { .. } = answer(&mut mind, "x", &LIST, &fork(2), &hash, Some(¬e)) else { panic!("the note was not stored") }; - grown += 1; - assert!(grown < 200, "the record does not grow"); + let mut added = 0; + while bare(&mind.desk_fork(&list(&LIST), &fork(n))) <= bytes { + let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(¬e)) else { panic!("the note was not stored") }; + added += 1; + assert!(added < 200, "the record does not grow"); } - // The newest fork pages; the one with the long record is refused when its page is asked for. - let response = mind.desk_forks(&list(&LIST), None, ForkCursor::new(docs_order(&mind, &fork(3))), limit(50)); - let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, bytes, limit: bound }) = response else { panic!("{response:?}") }; - assert_eq!((named, bound), (fork(2), DESK_PAGE_BUDGET as u64)); - assert!(bytes > bound); - // And a read of that fork alone is still its whole entry. - entry(mind.desk_fork(&list(&LIST), &fork(2))); } fn docs_order(mind: &Mind, key: &str) -> u64 { @@ -1049,57 +1042,294 @@ mod tests { Docs::at(image, image.head).ordinal_of(PipelineKind::Question, key).unwrap() } - /// Questions admitted in one commit share an order, and the cursor is "strictly below an order": - /// a page never ends inside such a group (the page may pass `limit` by the group's size). - #[test] - fn a_page_never_splits_a_group_admitted_in_one_commit() { - let mut mind = seeded(); - committed(admit(&mut mind, vec![question_n(1)])); - committed(admit(&mut mind, (2..=6).map(question_n).collect())); - committed(admit(&mut mind, vec![question_n(7)])); - // Newest first; the key breaks a tie inside the group. - let mut group: Vec = (2..=6).map(fork).collect(); - group.sort(); - group.reverse(); - let expected: Vec = std::iter::once(fork(7)).chain(group).chain(std::iter::once(fork(1))).collect(); - for size in [1, 2, 3, 5, 6] { - let all = pages(&mind, size); - let seen: Vec = all.iter().flat_map(|(_, page)| ids(page)).collect(); - assert_eq!(seen, expected, "limit {size}: no fork skipped or repeated"); - for (_, page) in &all { - let orders: Vec = page.forks.iter().map(|entry| entry.card.order).collect(); - let group = orders.iter().filter(|order| **order == docs_order(&mind, &fork(2))).count(); - assert!(group == 0 || group == 5, "limit {size}: the group of five is whole or absent, not {group}"); - } - } - // The first page at limit 2 runs past the limit to finish the group it ends in. - let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(2))); - assert_eq!(first.forks.len(), 6); - assert_eq!(first.next_cursor.map(ForkCursor::order), Some(docs_order(&mind, &fork(2)))); + fn orders(list: &ForkList) -> Vec { + list.forks.iter().map(|entry| entry.card.order).collect() } - /// A group the page budget would end inside goes whole to the next page; one that is over the - /// budget by itself is refused by name of its first fork. + /// A fork whose entry alone is past the budget cannot be paged: the page that starts at it is the + /// refusal naming it and its order, the forks before it page, and the cursor the refusal names + /// reaches the forks below it. The list is not bricked. #[test] - fn a_group_past_the_byte_budget_moves_whole_or_is_refused() { + fn a_lone_oversize_entry_is_refused_by_name_and_the_older_forks_are_still_reachable() { + let mut mind = desk(3); + grown_past(&mut mind, 2, DESK_PAGE_BUDGET); + // The newest fork pages alone: the oversize one would bust the page beside it. + let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50))); + assert_eq!(ids(&first), [fork(3)]); + assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2)); + let response = mind.desk_forks(&list(&LIST), None, first.next_cursor, limit(50)); + let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, order, bytes, limit: bound }) = response else { panic!("{response:?}") }; + assert_eq!((named, order, bound), (fork(2), 1, DESK_PAGE_BUDGET as u64)); + assert!(bytes > bound); + // The order the refusal names is the order of the fork it names; its cursor goes on past it. + assert_eq!(entry(mind.desk_fork(&list(&LIST), &fork(2))).0.card.order, order); + let (rest, _) = page(mind.desk_forks(&list(&LIST), None, ForkCursor::new(order), limit(50))); + assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None)); + } + + /// A batch of 64 questions admitted in ONE commit pages whole at every limit: no page passes its + /// limit or 50, and the pages are every fork once, newest first, with unique orders. + #[test] + fn a_batch_admitted_in_one_commit_pages_every_fork_once_newest_first() { let mut mind = seeded(); - committed(admit(&mut mind, (1..=50).map(wordy).collect())); - let response = mind.desk_forks(&list(&LIST), None, None, limit(50)); - let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, .. }) = response else { panic!("{response:?}") }; - assert!(named.starts_with("eureka-state:question:Q")); - let mut mind = seeded(); - committed(admit(&mut mind, vec![wordy(1)])); - committed(admit(&mut mind, (2..=21).map(wordy).collect())); - for n in 22..=29 { - committed(admit(&mut mind, vec![wordy(n)])); - } - let all = pages(&mind, 50); - let sizes: Vec = all.iter().map(|(_, page)| page.forks.len()).collect(); - assert_eq!(sizes, [8, 21], "the eight newest fill the first page; the group of twenty does not fit beside them and moves whole"); - for (response, page) in &all { - assert!(on_the_wire(response) <= DESK_MAX_RESPONSE_BYTES); - let group = page.forks.iter().filter(|entry| entry.card.order == docs_order(&mind, &fork(2))).count(); - assert!(group == 0 || group == 20, "the group of 20 is whole or absent, not {group}"); + committed(admit(&mut mind, (1..=64).map(question_n).collect())); + let mut expected: Vec = (1..=64).map(fork).collect(); + expected.sort(); + expected.reverse(); + for size in [1, 2, 3, 50] { + let all = pages(&mind, size); + let seen: Vec = all.iter().flat_map(|(_, page)| ids(page)).collect(); + assert_eq!(seen, expected, "limit {size}: every fork once, newest first"); + for (_, page) in &all { + assert!(page.forks.len() <= size as usize && page.forks.len() <= 50, "limit {size}: a page of {}", page.forks.len()); + } + let seen: Vec = all.iter().flat_map(|(_, page)| orders(page)).collect(); + assert_eq!(seen, (0..64).rev().collect::>(), "limit {size}: the orders are the ranks 63 down to 0"); } } + + /// A rank is the position in (admission, key) order among all the questions, so a question admitted + /// later is the newest whatever its key, and a cursor held from before still resumes correctly. + #[test] + fn a_rank_is_stable_when_more_questions_are_admitted() { + let mut mind = seeded(); + committed(admit(&mut mind, (1..=5).map(question_n).collect())); + let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(2))); + assert_eq!((orders(&first), first.next_cursor.map(ForkCursor::order)), (vec![4, 3], Some(3))); + let held = first.next_cursor; + let (before, _) = page(mind.desk_forks(&list(&LIST), None, held, limit(50))); + committed(admit(&mut mind, vec![question_n(6)])); + committed(admit(&mut mind, vec![question_n(0)])); + let (after, _) = page(mind.desk_forks(&list(&LIST), None, held, limit(50))); + assert_eq!(after, ForkList { stamp: after.stamp.clone(), ..before.clone() }, "the cursor from before resumes the same forks"); + let everything = all(&mind, 50); + assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]); + assert_eq!(orders(&everything), [6, 5, 4, 3, 2, 1, 0], "Q0 sorts first by key and is still the newest"); + } + + /// An unshowable question keeps its rank, so the others' ranks leave a gap and do not shift, and the + /// call's deciders list changes nothing about a rank. + #[test] + fn an_unshowable_question_leaves_a_gap_and_never_shifts_a_rank() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question_n(1)])); + let mut blank = question("Q2", &["A", "B"], "A"); + let D::Question(held) = &mut blank else { unreachable!() }; + held.depends = vec!["Cut 3a".into(), "".into()]; + if !matches!(admit(&mut mind, vec![blank]), PipelineAdmissionOutcome::Committed { .. }) { + let mut blank = question("Q2", &["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])); + } + committed(admit(&mut mind, vec![question_n(3)])); + for who in [&LIST[..], &["x"][..], &["z"][..]] { + let (shown, _) = page(mind.desk_forks(&list(who), None, None, limit(50))); + assert_eq!((ids(&shown), orders(&shown)), (vec![fork(3), fork(1)], vec![2, 0]), "{who:?}: rank 1 is the gap"); + assert_eq!(entry(mind.desk_fork(&list(who), &fork(3))).0.card.order, 2, "{who:?}"); + } + committed(admit(&mut mind, vec![question_n(4)])); + assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]); + } + + /// A page where one entry fits and the next busts the budget is that one entry and a cursor; it is + /// neither empty nor a refusal. + #[test] + fn a_page_of_one_when_the_next_entry_busts_the_budget() { + let mut mind = desk(2); + for n in [1, 2] { + grown_past(&mut mind, n, DESK_PAGE_BUDGET * 55 / 100); + assert!(bare(&mind.desk_fork(&list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits"); + } + let (one, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50))); + assert_eq!((ids(&one), one.next_cursor.map(ForkCursor::order)), (vec![fork(2)], Some(1))); + let (rest, _) = page(mind.desk_forks(&list(&LIST), None, one.next_cursor, limit(50))); + assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None)); + } + + /// The invariants of `order`: a unique 0-based rank by (admission, key), stable under later + /// admissions, order 0 legal and reached by the page boundary `c1`. + #[test] + fn order_is_a_unique_zero_based_rank_stable_under_appends() { + let mut mind = seeded(); + committed(admit(&mut mind, (1..=64).map(question_n).collect())); + let mut keys: Vec = (1..=64).map(fork).collect(); + keys.sort(); + let ranks = |mind: &Mind| -> Vec<(String, u64)> { + let mut out: Vec<(String, u64)> = pages(mind, 50).iter().flat_map(|(_, page)| page.forks.iter().map(|e| (e.card.fork_id.clone(), e.card.order))).collect(); + out.sort(); + out + }; + let before = ranks(&mind); + assert_eq!(before.iter().map(|(_, order)| *order).collect::>(), (0..64).collect(), "distinct, 0 to 63"); + for (rank, key) in keys.iter().enumerate() { + assert_eq!(before.iter().find(|(id, _)| id == key).unwrap().1, rank as u64, "ties inside one commit break on the key"); + } + committed(admit(&mut mind, vec![question_n(65)])); + committed(admit(&mut mind, (66..=70).map(question_n).collect())); + let after = ranks(&mind); + for row in &before { + assert!(after.contains(row), "{row:?} kept its rank"); + } + assert_eq!(after.len(), 70); + assert_eq!(after.iter().map(|(_, order)| *order).collect::>(), (0..70).collect()); + // The page boundary at c1: the page ending at order 1 names c1, and the next page is order 0 alone. + let (upper, _) = page(mind.desk_forks(&list(&LIST), None, ForkCursor::new(3), limit(2))); + assert_eq!((orders(&upper), upper.next_cursor.map(ForkCursor::order)), (vec![2, 1], Some(1))); + let (last, _) = page(mind.desk_forks(&list(&LIST), None, upper.next_cursor, limit(50))); + assert_eq!((orders(&last), last.next_cursor), (vec![0], None)); + } + + /// No admission path reorders the ranks: a re-admitted question (replayed batch, the same document + /// in a new batch, a rewrite, a resolution of it, an import of it or of a new one) keeps every + /// existing rank, and a question that is new is admitted last whatever its key. + #[test] + fn no_admission_path_moves_an_existing_rank() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question_n(2), question_n(3)])); + committed(admit(&mut mind, vec![question_n(5)])); + let ranks = |mind: &Mind| -> Vec<(String, u64)> { + pages(mind, 50).iter().flat_map(|(_, page)| page.forks.iter().map(|e| (e.card.fork_id.clone(), e.card.order))).collect() + }; + let base = ranks(&mind); + assert_eq!(base.iter().map(|(_, order)| *order).collect::>(), [2, 1, 0]); + let held = |mind: &Mind| { + let now = ranks(mind); + assert!(base.iter().all(|row| now.contains(row)), "every existing rank held: {now:?}"); + now.len() + }; + // The same document again in a new batch, and a rewrite of it. + let again = admit(&mut mind, vec![question_n(2)]); + assert!(!matches!(again, PipelineAdmissionOutcome::Committed { .. }), "{again:?}"); + let mut rewritten = question_n(3); + let D::Question(body) = &mut rewritten else { unreachable!() }; + body.question = "Another reading entirely?".into(); + assert!(!matches!(admit(&mut mind, vec![rewritten]), PipelineAdmissionOutcome::Committed { .. })); + assert_eq!(held(&mind), 3); + // A batch replayed whole. + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents: vec![question_n(7)] }; + committed(mind.admit(batch.clone(), now())); + let replay = mind.admit(batch, now()); + assert!(matches!(replay, PipelineAdmissionOutcome::AlreadyAdmitted { .. }), "{replay:?}"); + assert_eq!(held(&mind), 4); + // A resolution of a question changes its status and not its rank. + let subject = PipelineRef { kind: PipelineKind::Question, id: s(&fork(2)) }; + let withdrawn = crate::fixtures::resolution(subject, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "no".into() }); + committed(admit(&mut mind, vec![withdrawn])); + assert_eq!(held(&mind), 4); + // A new question whose key sorts before them all is the newest. + committed(admit(&mut mind, vec![question_n(0)])); + assert_eq!(held(&mind), 5); + assert_eq!(ranks(&mind)[0], (fork(0), 4)); + // An import carries the source's ordinal and the Mind's own receipt orders it: appended, last. + let source = crate::receipt::ImportSource { + instance: slug(INSTANCE), + receipt_id: s("mind-commit-source"), + ordinal: 1, + committed_at: s("2026-11-08T16:20:05Z"), + faculty: Faculty::Imagination, + agent: s("imagination"), + session: s("source-session"), + tool: s("huginn"), + external: vec![], + }; + let mut abroad = question_n(1); + let D::Question(body) = &mut abroad else { unreachable!() }; + body.campaign = slug("other-campaign"); + let import = |documents: Vec| PipelineAdmissionBatch { + instance: INSTANCE.into(), + provenance: crate::receipt::PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s("self"), session: s("import-1"), tool: s("huginn"), imported_from: Some(source.clone()) }, + documents, + }; + let outcome = mind.admit(import(vec![abroad.clone()]), now()); + let imported = matches!(outcome, PipelineAdmissionOutcome::Committed { .. }); + assert_eq!(held(&mind), 5 + usize::from(imported), "{outcome:?}"); + let replay = mind.admit(import(vec![abroad]), now()); + assert!(!matches!(replay, PipelineAdmissionOutcome::Committed { .. }), "{replay:?}"); + assert_eq!(held(&mind), 5 + usize::from(imported)); + if imported { + assert_eq!(ranks(&mind)[0].1, 5, "the import is the newest, whatever ordinal its source gave it"); + } + } + + /// A closed card names the in-force ruling that answers THIS fork, never another fork's. + #[test] + fn a_closed_card_names_the_ruling_that_answers_it() { + let mut mind = desk(3); + close(&mut mind, &fork(1), "R9"); + close(&mut mind, &fork(2), "R2"); + let named = |n: u32| entry(mind.desk_fork(&list(&LIST), &fork(n))).0.card.ruling; + assert_eq!(named(1).as_deref(), Some("eureka-state:ruling:R9")); + assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2")); + assert_eq!(named(3), None); + } + + /// The response a page of the budget makes, in the worst envelope the door can echo (a 256-byte + /// message id, a 64-byte slug in the runtime id), is within the bound; the margin is real. + #[test] + fn a_page_at_the_budget_in_the_worst_envelope_is_within_the_response_bound() { + let mind = admitted_each(1..=50); + let worst = format!("huginn-{}", "s".repeat(64)); + let all = pages(&mind, 50); + assert!(all.len() > 1); + for (response, _) in &all { + let framed = 4 + encoded(&"m".repeat(256), &worst, response); + assert!(framed <= DESK_MAX_RESPONSE_BYTES, "{framed} bytes framed"); + let overhead = encoded(&"m".repeat(256), &worst, response) - bare(response); + assert!(overhead > 0 && overhead < DESK_ENVELOPE_MARGIN, "the envelope is {overhead} bytes against a margin of {DESK_ENVELOPE_MARGIN}"); + } + // And the fullest page is within the margin's reach of the bound. + let fullest = all.iter().map(|(response, _)| bare(response)).max().unwrap(); + assert!(fullest > DESK_PAGE_BUDGET - 3000, "the pages fill to the budget, not far short of it: {fullest}"); + } + + /// Libby's list scenarios (CultLib e57fbaee `listScenarios`) replayed through `desk_forks` over the + /// data the Mind stores: five forks in five commits, the first and third closed by a ruling, the + /// fifth answered, the third answered before and after it closed. What the Mind does not store the + /// same way is not compared: the ruling's id (the vectors name a `resolution`, the Mind a `ruling`), + /// `ruled_from` (always empty here), the answerers and their authority, timestamps and texts. + #[test] + fn libbys_list_scenarios_replay_through_desk_forks() { + let file = transport(); + let mut replayed = 0; + for scenario in file["listScenarios"].as_array().unwrap() { + let mut mind = seeded(); + if !scenario["entries"].as_array().unwrap().is_empty() { + for n in 1..=5 { + committed(admit(&mut mind, vec![question_n(n)])); + } + for n in [3, 5] { + let hash = hash_of(&mind, &fork(n)); + answer(&mut mind, "x", &LIST, &fork(n), &hash, None); + } + close(&mut mind, &fork(1), "R1"); + close(&mut mind, &fork(3), "R3"); + let hash = hash_of(&mind, &fork(3)); + answer(&mut mind, "y", &LIST, &fork(3), &hash, None); + } + for step in scenario["steps"].as_array().unwrap() { + let (request, want) = (&step["request"], &step["response"]); + let status: Option = serde_json::from_value(request["status"].clone()).unwrap(); + // Her orders count from 1 (the admission ordinal) and the Mind's from 0 (a rank): every order + // and cursor in her scenarios is one more than the Mind's. + let cursor: Option = serde_json::from_value::>(request["cursor"].clone()).unwrap().and_then(|her| ForkCursor::new(her.order() - 1)); + let (got, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(request["limit"].as_u64().unwrap() as u8))); + let name = &scenario["name"]; + assert_eq!((got.open_count, got.recorded_count), (want["open_count"].as_u64().unwrap(), want["recorded_count"].as_u64().unwrap()), "{name}"); + assert_eq!(serde_json::to_value(got.next_cursor.and_then(|ours| ForkCursor::new(ours.order() + 1))).unwrap(), want["next_cursor"], "{name}"); + let wanted = want["forks"].as_array().unwrap(); + assert_eq!(got.forks.len(), wanted.len(), "{name}"); + for (have, want) in got.forks.iter().zip(wanted) { + assert_eq!(have.card.order + 1, want["card"]["order"].as_u64().unwrap(), "{name}"); + assert_eq!(serde_json::to_value(have.card.status).unwrap(), want["card"]["status"], "{name}"); + assert_eq!(have.record.counted_answer_id.as_deref(), want["record"]["counted_answer_id"].as_str(), "{name}"); + let kept: Vec<_> = have.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.outcome.as_str())).collect(); + let wanted: Vec<_> = want["record"]["answers"].as_array().unwrap().iter().map(|a| (a["answer_id"].as_str().unwrap(), a["outcome"].as_str().unwrap())).collect(); + assert_eq!(kept, wanted, "{name}"); + } + replayed += 1; + } + } + assert_eq!(replayed, 7); + } } diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 5987d04..047afd1 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -109,11 +109,11 @@ pub enum MindRefusal { /// truncated or paginated on its behalf. The daemon owns it and its /// deferral bound decides it; no rule of the mind is involved. ResponseTooLarge { bytes: u64, limit: u64 }, - /// A desk page that cannot hold even its first entry (or the whole group of forks admitted - /// in one commit that the cursor will not split): `fork` is the first fork of that group, `bytes` - /// what the page holding it would encode to (without the envelope) and `limit` the page budget - /// it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`. - DeskForkTooLarge { fork: String, bytes: u64, limit: u64 }, + /// A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its + /// rank (a caller goes on past it with the cursor `c`, which returns the forks below it), + /// `bytes` what the page holding it would encode to (without the envelope) and `limit` the page + /// budget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`. + DeskForkTooLarge { fork: String, order: u64, bytes: u64, limit: u64 }, /// A selection the substrate or the organ's value door refused: `field` /// is the selection field the client sent, `value` the offending part. SelectionInvalid { field: String, value: Option, message: String }, diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index 0c1555a..df3a476 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -1562,7 +1562,7 @@ ] }, { - "description": "A desk page that cannot hold even its first entry (or the whole group of forks admitted\nin one commit that the cursor will not split): `fork` is the first fork of that group, `bytes`\nwhat the page holding it would encode to (without the envelope) and `limit` the page budget\nit exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", + "description": "A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its\nrank (a caller goes on past it with the cursor `c`, which returns the forks below it),\n`bytes` what the page holding it would encode to (without the envelope) and `limit` the page\nbudget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", "type": "object", "properties": { "DeskForkTooLarge": { @@ -1580,10 +1580,16 @@ "type": "integer", "format": "uint64", "minimum": 0 + }, + "order": { + "type": "integer", + "format": "uint64", + "minimum": 0 } }, "required": [ "fork", + "order", "bytes", "limit" ] diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 83a6257..d514d50 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -2060,7 +2060,7 @@ ] }, { - "description": "A desk page that cannot hold even its first entry (or the whole group of forks admitted\nin one commit that the cursor will not split): `fork` is the first fork of that group, `bytes`\nwhat the page holding it would encode to (without the envelope) and `limit` the page budget\nit exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", + "description": "A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its\nrank (a caller goes on past it with the cursor `c`, which returns the forks below it),\n`bytes` what the page holding it would encode to (without the envelope) and `limit` the page\nbudget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.", "type": "object", "properties": { "DeskForkTooLarge": { @@ -2078,10 +2078,16 @@ "type": "integer", "format": "uint64", "minimum": 0 + }, + "order": { + "type": "integer", + "format": "uint64", + "minimum": 0 } }, "required": [ "fork", + "order", "bytes", "limit" ] From 9e7dca27162817d5593812c94a45155c3b56b81a Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 00:36:48 +0000 Subject: [PATCH 9/9] Desk reads: order is a unique 0-based rank by (admission, key); pages hold at most limit and stop at the byte budget; a lone oversize first entry is the named refusal with its order; vectors re-vendored from CultLib 9d6ee72e Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/desk_door_tests.rs | 48 +- crates/huginn-mind/src/desk.rs | 3 +- crates/huginn-mind/src/desk_wire/reads.rs | 260 +++--- .../vectors/fork-card-vectors.json | 738 ++++++++++++++++++ .../huginn.desk_response.v1.schema.json | 1 + 5 files changed, 951 insertions(+), 99 deletions(-) diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index 1403199..d0ffa11 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -816,6 +816,45 @@ mod door_smoke { assert_eq!(rig.state(), before); } + /// The daemon hands a list's `status` and `cursor` to the Mind, whole: a recorded-only list is the + /// one closed fork, an open-only list the open ones, and a cursor page is the forks below it; read + /// both in the daemon and over the socket. + #[test] + fn the_status_and_the_cursor_reach_the_mind() { + let mut rig = rig(my_uid()); + let (question, ruling) = question_and_ruling(); + let PipelineDocument::Question(held) = &question else { unreachable!() }; + let more: Vec = (2..=3) + .map(|n| { + let mut next = held.clone(); + next.label = format!("Q{n}").as_str().into(); + PipelineDocument::Question(next) + }) + .collect(); + for documents in [more, vec![ruling]] { + let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), now()).answered(); + assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}"); + } + let ids = |list: &ForkList| list.forks.iter().map(|entry| entry.card.fork_id.clone()).collect::>(); + let q = |n: u32| format!("eureka-state:question:Q{n}"); + let cases = [ + (Some(ListStatus::Recorded), None, vec![q(1)]), + (Some(ListStatus::Open), None, vec![q(3), q(2)]), + (None, None, vec![q(3), q(2), q(1)]), + (None, ForkCursor::new(2), vec![q(2), q(1)]), + (Some(ListStatus::Open), ForkCursor::new(2), vec![q(2)]), + ]; + let now = now(); + for (status, cursor, expected) in cases { + let request = forks("x", &["x"], status, cursor, 50); + let (list, _) = page_of(rig.daemon.handle_desk(request.clone(), now)); + assert_eq!(ids(&list), expected, "{status:?} {cursor:?} in the daemon"); + let (list, _) = page_of(rig.serving(|path| reply_of(path, "m-1", &request))); + assert_eq!(ids(&list), expected, "{status:?} {cursor:?} over the socket"); + assert_eq!((list.open_count, list.recorded_count), (2, 1)); + } + } + /// 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] @@ -904,8 +943,8 @@ mod door_smoke { /// Fifty wordy forks, one commit each. A limit-50 page of them would pass the response bound: the Mind /// stops the page short at its budget and names the cursor, so the door sends it whole; the door's own - /// measure stays exact at the bound. A group of them admitted in one commit that is past the budget by - /// itself comes back as the Mind's refusal naming its first fork. + /// measure stays exact at the bound. Fifty of them admitted in one commit page like any other: the + /// ranks are unique, so the Mind stops the page at its budget inside the commit. #[test] fn a_page_is_short_of_the_response_bound_and_the_door_still_measures_it_exactly() { let mut rig = rig(my_uid()); @@ -923,9 +962,8 @@ mod door_smoke { assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}"); let before = rig.state(); let grouped = rig.serving(|path| reply_of(path, "m-0", &forks_all(&["x"]))); - let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { bytes, limit, .. }) = grouped else { panic!("{grouped:?}") }; - assert_eq!(limit, huginn_mind::desk_wire::DESK_PAGE_BUDGET as u64); - assert!(bytes > limit); + let (grouped, _) = page_of(grouped); + assert!(grouped.forks.len() > 1 && grouped.forks.len() < 50 && grouped.next_cursor.is_some(), "{} entries", grouped.forks.len()); assert_eq!(rig.state(), before); // Then fifty more, each in its own commit, newer than the group. for n in 52..=101 { diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs index 6c5d975..7d66d2c 100644 --- a/crates/huginn-mind/src/desk.rs +++ b/crates/huginn-mind/src/desk.rs @@ -7,8 +7,7 @@ //! request id). It builds one `desk_answer` and commits it through //! `admit_steps` with `Door::Desk`, the same commit primitive every other write //! uses; no other door may write the kind. `answer_desk` and `DeskStamp` are -//! crate-private: only code inside this crate can stamp, so the verified door -//! that will call them (follow-up desk-answer-door) widens them in its own cut. +//! crate-private: only code inside this crate can stamp. //! //! The decider list is an input (`DeskStamp.deciders`, from the door); the Mind //! never reads one. Whether an answerer decides is membership in that list and diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 44431a3..2b8e3e1 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -94,6 +94,8 @@ pub struct ForkCard { #[schemars(required)] pub ruling: Option, pub ruled_from: Vec, + /// The question's rank: unique, 0-based, in (admission ordinal, key) order among all the Mind's + /// questions (see `Mind::desk_forks`; it deviates from "the admission ordinal" only in being unique). pub order: u64, pub options_hash: String, } @@ -226,7 +228,7 @@ impl JsonSchema for ForkLimit { } /// The card of a question held at the snapshot `docs` reads, shown to the call's `deciders`. -fn card_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { +fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short]) -> ForkCard { let open = docs.in_force(PipelineKind::Question, key); // The ruling that closed the fork: the in-force one that answers it (the key order picks if a second ever does). let ruling = (!open) @@ -240,7 +242,7 @@ fn card_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[ .min() }) .flatten(); - Ok(ForkCard { + ForkCard { schema_version: CardSchema::V0, fork_id: key.into(), title: question.title.0.clone(), @@ -255,9 +257,22 @@ fn card_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[ status: if open { CardStatus::Open } else { CardStatus::Closed }, ruling, ruled_from: Vec::new(), - order: docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?, + order, options_hash: options_hash(question).0, - }) + } +} + +/// Every question the Mind holds at the snapshot, showable or not, with its rank: the 0-based position +/// in ascending (admission ordinal, key) order. A rank is unique, and admissions only append, so a held +/// question's rank never moves; a question the deciders do not show leaves a gap and shifts no other. +fn ranked<'d>(docs: &'d Docs<'_>) -> Result, MindRefusal> { + let mut held = Vec::new(); + for (key, document) in docs.of_kind(PipelineKind::Question) { + let PipelineDocument::Question(question) = document else { continue }; + held.push((docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?, key, question)); + } + held.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(right.1))); + Ok(held.into_iter().enumerate().map(|(rank, (_, key, question))| (rank as u64, key, question)).collect()) } /// The record of a fork: its answers in admission order and the answer that counts at this snapshot. @@ -285,8 +300,8 @@ fn held(response: &mut DeskResponse) -> &mut ForkList { } } -fn entry_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { - let card = card_of(docs, key, question, deciders)?; +fn entry_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short]) -> Result { + let card = card_of(docs, key, order, question, deciders); let record = record_of(docs, key, &card.options_hash)?; Ok(ForkEntry { schema_version: EntrySchema::V0, card, record }) } @@ -297,19 +312,23 @@ impl Mind { /// The showable forks only: a question `showable` refuses (fewer than two options, a blank option /// or depends line, no decider) is in no count and no page. `open_count` and `recorded_count` cover /// that whole set whatever `status`, `cursor` and `limit` ask. The page is the forks newest first by - /// `order` (the question's admission ordinal), those with the asked `status` (`None` is both) and - /// an order strictly below the cursor's; `limit` 0 returns no entries. `next_cursor` names the last - /// order returned and is `None` when no further fork matches. + /// `order`, those with the asked `status` (`None` is both) and an order strictly below the cursor's; + /// `limit` 0 returns no entries. `next_cursor` names the last order returned and is `None` when no + /// further fork matches. /// - /// A page is filled until `limit` entries are held or the next entry would push the encoded - /// response past `DESK_PAGE_BUDGET` (the one `DESK_MAX_RESPONSE_BYTES` less the envelope margin), so - /// it may hold fewer than `limit`; counts and stamp are unaffected. Questions admitted in one commit - /// share an `order`, and the contract's cursor ("an order strictly below N") has no tie-break, so - /// this is Huginn's rule pending a CultLib contract note: a page never splits such a group. When - /// `limit` or the budget would end the page inside a group the whole group is taken (the page may - /// pass `limit` by the group's size), or, when the budget forbids that, the whole group is left for - /// the next page. A page that cannot hold its first group is refused as - /// `MindRefusal::DeskForkTooLarge`, naming that group's first fork; nothing is dropped silently. + /// `order` deviates from "the question's admission ordinal" only in being unique: it is the 0-based + /// rank of the question in ascending (admission ordinal, key) order among ALL the Mind's questions, + /// showable or not. Questions admitted in one commit share an ordinal, and the cursor ("an order + /// strictly below N") has no tie-break, so the rank breaks the tie by key. New admissions append, so + /// a rank never moves; an unshowable question leaves a gap. Order 0 is legal and no cursor names it + /// (`c0` is no cursor), so the page after one ending at order 1 is order 0 alone. + /// + /// A page holds at most `limit` entries (never more than 50) and stops short only when the next + /// entry would push the encoded response past `DESK_PAGE_BUDGET` (the one `DESK_MAX_RESPONSE_BYTES` + /// less the envelope margin); counts and stamp are unaffected. A page whose FIRST entry alone is past + /// the budget is `MindRefusal::DeskForkTooLarge`, naming that fork and its order: the caller goes on + /// with the cursor `c`, which returns the forks below it, so no older fork is unreachable. + /// The fork itself is still its whole entry on `desk_fork`. Nothing is dropped silently. /// /// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and /// feed the showability gate, and gate nothing else. The record is whole, notes included @@ -327,16 +346,8 @@ impl Mind { fn forks_page(&self, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> Result { let image = self.image()?; let docs = Docs::at(image, image.head); - let mut shown = Vec::new(); - for (key, document) in docs.of_kind(PipelineKind::Question) { - let PipelineDocument::Question(question) = document else { continue }; - if showable(question, deciders) { - let order = docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?; - shown.push((order, key, question, docs.in_force(PipelineKind::Question, key))); - } - } - // Newest first; the key breaks a tie so that a page is the same page on every read. - shown.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(left.1))); + let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, _, question)| showable(question, deciders)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect(); + shown.reverse(); let open_count = shown.iter().filter(|fork| fork.3).count() as u64; let recorded_count = shown.len() as u64 - open_count; let matching: Vec<_> = shown @@ -362,27 +373,23 @@ impl Mind { }, as_of: image.head, }; - let (mut taken, mut last_order) = (0, None); - while taken < matching.len() && held(&mut response).forks.len() < limit.get() as usize { - // A group admitted in one commit shares an order and goes whole (see the doc comment). - let order = matching[taken].0; - let end = taken + matching[taken..].iter().take_while(|fork| fork.0 == order).count(); - let kept = held(&mut response).forks.len(); - for (_, key, question, _) in &matching[taken..end] { - held(&mut response).forks.push(entry_of(&docs, key, question, deciders)?); - } - held(&mut response).next_cursor = if end < matching.len() { ForkCursor::new(order) } else { None }; - let bytes = page_bytes(&response)?; - let list = held(&mut response); - if bytes > DESK_PAGE_BUDGET { - if kept == 0 { - return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: matching[taken].1.to_string(), order: matching[taken].0, bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 })); - } - list.forks.truncate(kept); - list.next_cursor = last_order.and_then(ForkCursor::new); + for (at, (order, key, question, _)) in matching.iter().enumerate() { + if held(&mut response).forks.len() == limit.get() as usize { + break; + } + held(&mut response).forks.push(entry_of(&docs, key, *order, question, deciders)?); + let before = held(&mut response).next_cursor; + held(&mut response).next_cursor = if at + 1 < matching.len() { ForkCursor::new(*order) } else { None }; + let bytes = page_bytes(&response)?; + if bytes > DESK_PAGE_BUDGET { + let list = held(&mut response); + if list.forks.len() == 1 { + return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: key.to_string(), order: *order, bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 })); + } + list.forks.pop(); + list.next_cursor = before; break; } - (taken, last_order) = (end, Some(order)); } Ok(response) } @@ -390,13 +397,13 @@ impl Mind { fn fork_entry(&self, deciders: &[Short], fork_id: &str) -> Result { let image = self.image()?; let docs = Docs::at(image, image.head); - let Some(PipelineDocument::Question(question)) = docs.in_image(PipelineKind::Question, fork_id) else { + let Some((order, key, question)) = ranked(&docs)?.into_iter().find(|(_, key, _)| *key == fork_id) else { return Ok(DeskResponse::NotStored(fork_unknown())); }; if !showable(question, deciders) { return Ok(DeskResponse::NotStored(fork_unknown())); } - Ok(DeskResponse::Fork { entry: entry_of(&docs, fork_id, question, deciders)?, as_of: image.head }) + Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders)?, as_of: image.head }) } } @@ -789,7 +796,7 @@ mod tests { 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); + assert_eq!(scenarios.len(), 6); let mut steps = 0; for scenario in scenarios { for step in scenario["steps"].as_array().unwrap() { @@ -800,7 +807,7 @@ mod tests { steps += 1; } } - assert_eq!(steps, 7); + assert_eq!(steps, 9); } #[test] @@ -1037,10 +1044,6 @@ mod tests { } } - fn docs_order(mind: &Mind, key: &str) -> u64 { - let image = mind.image().unwrap(); - Docs::at(image, image.head).ordinal_of(PipelineKind::Question, key).unwrap() - } fn orders(list: &ForkList) -> Vec { list.forks.iter().map(|entry| entry.card.order).collect() @@ -1101,27 +1104,32 @@ mod tests { committed(admit(&mut mind, vec![question_n(6)])); committed(admit(&mut mind, vec![question_n(0)])); let (after, _) = page(mind.desk_forks(&list(&LIST), None, held, limit(50))); - assert_eq!(after, ForkList { stamp: after.stamp.clone(), ..before.clone() }, "the cursor from before resumes the same forks"); + assert_eq!((after.forks, after.next_cursor), (before.forks, before.next_cursor), "the cursor from before resumes the same forks"); let everything = all(&mind, 50); assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]); assert_eq!(orders(&everything), [6, 5, 4, 3, 2, 1, 0], "Q0 sorts first by key and is still the newest"); } + /// Admits a question `showable` refuses, under `label`: a rank nobody sees. + fn admit_unshowable(mind: &mut Mind, label: &str) { + let mut blank = question(label, &["A", "B"], "A"); + let D::Question(held) = &mut blank else { unreachable!() }; + held.depends = vec!["Cut 3a".into(), "".into()]; + if !matches!(admit(mind, vec![blank]), PipelineAdmissionOutcome::Committed { .. }) { + let mut blank = question(label, &["A", "B"], "A"); + let D::Question(held) = &mut blank else { unreachable!() }; + held.options[0] = QuestionOption { label: l("A"), text: "".into() }; + committed(admit(mind, vec![blank])); + } + } + /// An unshowable question keeps its rank, so the others' ranks leave a gap and do not shift, and the /// call's deciders list changes nothing about a rank. #[test] fn an_unshowable_question_leaves_a_gap_and_never_shifts_a_rank() { let mut mind = seeded(); committed(admit(&mut mind, vec![question_n(1)])); - let mut blank = question("Q2", &["A", "B"], "A"); - let D::Question(held) = &mut blank else { unreachable!() }; - held.depends = vec!["Cut 3a".into(), "".into()]; - if !matches!(admit(&mut mind, vec![blank]), PipelineAdmissionOutcome::Committed { .. }) { - let mut blank = question("Q2", &["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])); - } + admit_unshowable(&mut mind, "Q2"); committed(admit(&mut mind, vec![question_n(3)])); for who in [&LIST[..], &["x"][..], &["z"][..]] { let (shown, _) = page(mind.desk_forks(&list(who), None, None, limit(50))); @@ -1223,7 +1231,7 @@ mod tests { assert_eq!(ranks(&mind)[0], (fork(0), 4)); // An import carries the source's ordinal and the Mind's own receipt orders it: appended, last. let source = crate::receipt::ImportSource { - instance: slug(INSTANCE), + instance: slug(crate::fixtures::OTHER_INSTANCE), receipt_id: s("mind-commit-source"), ordinal: 1, committed_at: s("2026-11-08T16:20:05Z"), @@ -1241,15 +1249,17 @@ mod tests { provenance: crate::receipt::PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s("self"), session: s("import-1"), tool: s("huginn"), imported_from: Some(source.clone()) }, documents, }; + let D::Campaign(mut other) = campaign(&[REPO]) else { unreachable!() }; + other.slug = slug("other-campaign"); + committed(mind.admit(import(vec![D::Campaign(other)]), now())); let outcome = mind.admit(import(vec![abroad.clone()]), now()); let imported = matches!(outcome, PipelineAdmissionOutcome::Committed { .. }); - assert_eq!(held(&mind), 5 + usize::from(imported), "{outcome:?}"); + assert!(imported, "{outcome:?}"); + assert_eq!(held(&mind), 6); let replay = mind.admit(import(vec![abroad]), now()); assert!(!matches!(replay, PipelineAdmissionOutcome::Committed { .. }), "{replay:?}"); - assert_eq!(held(&mind), 5 + usize::from(imported)); - if imported { - assert_eq!(ranks(&mind)[0].1, 5, "the import is the newest, whatever ordinal its source gave it"); - } + assert_eq!(held(&mind), 6); + assert_eq!(ranks(&mind)[0].1, 5, "the import is the newest, whatever ordinal its source gave it"); } /// A closed card names the in-force ruling that answers THIS fork, never another fork's. @@ -1283,44 +1293,56 @@ mod tests { assert!(fullest > DESK_PAGE_BUDGET - 3000, "the pages fill to the budget, not far short of it: {fullest}"); } - /// Libby's list scenarios (CultLib e57fbaee `listScenarios`) replayed through `desk_forks` over the - /// data the Mind stores: five forks in five commits, the first and third closed by a ruling, the - /// fifth answered, the third answered before and after it closed. What the Mind does not store the - /// same way is not compared: the ruling's id (the vectors name a `resolution`, the Mind a `ruling`), - /// `ruled_from` (always empty here), the answerers and their authority, timestamps and texts. + /// Libby's list scenarios (CultLib 9d6ee72e `listScenarios`) replayed through `desk_forks` over the + /// data the Mind stores: a question for every order the scenario's cards hold, an unshowable one + /// (a rank nobody sees) for every order below the highest that none holds, the closed cards closed + /// by a ruling and the answers she records answered, the closed card's second one after it closed. + /// What the Mind does not store the same way is not compared: the ruling's id (the vectors name a + /// `resolution`, the Mind a `ruling`), `ruled_from` (always empty here), the answerers and their + /// authority, timestamps and texts. #[test] fn libbys_list_scenarios_replay_through_desk_forks() { let file = transport(); let mut replayed = 0; for scenario in file["listScenarios"].as_array().unwrap() { let mut mind = seeded(); - if !scenario["entries"].as_array().unwrap().is_empty() { - for n in 1..=5 { - committed(admit(&mut mind, vec![question_n(n)])); + let entries = scenario["entries"].as_array().unwrap(); + let held: std::collections::BTreeMap = entries.iter().map(|entry| (entry["card"]["order"].as_u64().unwrap(), entry)).collect(); + for order in 0..=held.keys().next_back().copied().unwrap_or(0) { + match held.get(&order) { + _ if held.is_empty() => {} + Some(_) => { + committed(admit(&mut mind, vec![question_n(order as u32)])); + } + None => admit_unshowable(&mut mind, &format!("G{order}")), } - for n in [3, 5] { - let hash = hash_of(&mind, &fork(n)); - answer(&mut mind, "x", &LIST, &fork(n), &hash, None); + } + for (order, entry) in &held { + let key = fork(*order as u32); + let answers = entry["record"]["answers"].as_array().unwrap(); + let hash = hash_of(&mind, &key); + if !answers.is_empty() { + answer(&mut mind, "x", &LIST, &key, &hash, None); + } + if entry["card"]["status"] == "closed" { + close(&mut mind, &key, &format!("R{order}")); + } + for _ in answers.iter().skip(1) { + answer(&mut mind, "y", &LIST, &key, &hash, None); } - close(&mut mind, &fork(1), "R1"); - close(&mut mind, &fork(3), "R3"); - let hash = hash_of(&mind, &fork(3)); - answer(&mut mind, "y", &LIST, &fork(3), &hash, None); } for step in scenario["steps"].as_array().unwrap() { let (request, want) = (&step["request"], &step["response"]); let status: Option = serde_json::from_value(request["status"].clone()).unwrap(); - // Her orders count from 1 (the admission ordinal) and the Mind's from 0 (a rank): every order - // and cursor in her scenarios is one more than the Mind's. - let cursor: Option = serde_json::from_value::>(request["cursor"].clone()).unwrap().and_then(|her| ForkCursor::new(her.order() - 1)); + let cursor: Option = serde_json::from_value(request["cursor"].clone()).unwrap(); let (got, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(request["limit"].as_u64().unwrap() as u8))); let name = &scenario["name"]; assert_eq!((got.open_count, got.recorded_count), (want["open_count"].as_u64().unwrap(), want["recorded_count"].as_u64().unwrap()), "{name}"); - assert_eq!(serde_json::to_value(got.next_cursor.and_then(|ours| ForkCursor::new(ours.order() + 1))).unwrap(), want["next_cursor"], "{name}"); + assert_eq!(serde_json::to_value(got.next_cursor).unwrap(), want["next_cursor"], "{name}"); let wanted = want["forks"].as_array().unwrap(); assert_eq!(got.forks.len(), wanted.len(), "{name}"); for (have, want) in got.forks.iter().zip(wanted) { - assert_eq!(have.card.order + 1, want["card"]["order"].as_u64().unwrap(), "{name}"); + assert_eq!(have.card.order, want["card"]["order"].as_u64().unwrap(), "{name}"); assert_eq!(serde_json::to_value(have.card.status).unwrap(), want["card"]["status"], "{name}"); assert_eq!(have.record.counted_answer_id.as_deref(), want["record"]["counted_answer_id"].as_str(), "{name}"); let kept: Vec<_> = have.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.outcome.as_str())).collect(); @@ -1330,6 +1352,60 @@ mod tests { replayed += 1; } } - assert_eq!(replayed, 7); + assert_eq!(replayed, 9); + } + + /// `two_cards_share_one_order` is not a document the schema can refuse (CultLib: "not expressible in + /// the schema"), and Huginn has no judge of a list it receives: it only writes lists. Uniqueness is + /// by construction, `ranked` numbering the questions one by one, and is walked in + /// `ranks_are_distinct_across_every_walk`. + #[test] + fn a_list_sharing_an_order_is_no_schema_violation_and_the_mind_never_writes_one() { + let file = transport(); + let row = file["listInvalid"].as_array().unwrap().iter().find(|row| row["name"] == "two_cards_share_one_order").expect("the vector is vendored"); + assert!(row["refusal"].as_str().unwrap().contains("order 4 is shared by")); + let list: ForkList = serde_json::from_value(row["message"].clone()).unwrap(); + let mut seen = std::collections::BTreeSet::new(); + assert!(!list.forks.iter().all(|entry| seen.insert(entry.card.order)), "the vector does share an order"); + } + + /// Every full walk, at every page size and under each status, sees each rank once, descending, and + /// ends at rank 0 with no cursor. + #[test] + fn ranks_are_distinct_across_every_walk() { + let mut mind = seeded(); + committed(admit(&mut mind, (1..=12).map(question_n).collect())); + for n in 13..=24 { + committed(admit(&mut mind, vec![question_n(n)])); + } + // Every third fork is closed; the batch's ranks follow its keys, so the closed ranks are read, not computed. + let mut closed = std::collections::BTreeSet::new(); + for n in (1..=24).step_by(3) { + closed.insert(entry(mind.desk_fork(&list(&LIST), &fork(n))).0.card.order); + close(&mut mind, &fork(n), &format!("R{n}")); + } + for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { + let mut want: Vec = (0..24).filter(|rank| match status { + None => true, + Some(ListStatus::Recorded) => closed.contains(rank), + Some(ListStatus::Open) => !closed.contains(rank), + }).collect(); + want.reverse(); + for size in 1..=24_u8 { + let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0); + loop { + let (page, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(size))); + pages += 1; + assert!(page.forks.len() <= size as usize && pages < 100); + walked.extend(orders(&page)); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + assert_eq!(walked, want, "{status:?} at size {size}: every rank once, descending"); + } + } + assert_eq!(*orders(&all(&mind, 50)).last().unwrap(), 0, "the walk ends at order 0, whose page has no cursor"); } } diff --git a/crates/huginn-mind/vectors/fork-card-vectors.json b/crates/huginn-mind/vectors/fork-card-vectors.json index ea738e8..7c6f5df 100644 --- a/crates/huginn-mind/vectors/fork-card-vectors.json +++ b/crates/huginn-mind/vectors/fork-card-vectors.json @@ -3974,6 +3974,344 @@ "next_cursor": "c4" }, "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + }, + { + "name": "two_cards_share_one_order", + "why": "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s412", + "open_count": 4, + "recorded_count": 2, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q5", + "title": "Fork 5", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 5, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q5", + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "counted_answer_id": "n1", + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q5", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000020-0000-4000-8000-000000000020" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4", + "title": "Fork 4", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q4b", + "title": "Fork 4", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4b", + "options_hash": "a64604237f906b6fec9cb292863685d966abf8048f276afa24cd4f1e1c4c74aa", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q3.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": "n1", + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000021-0000-4000-8000-000000000021" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:q3", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:04:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000022-0000-4000-8000-000000000022" + } + ] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.q1.n1", + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7402A5666F726B739683AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0", + "refusal": "order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b" } ], "listScenarios": [ @@ -5796,6 +6134,406 @@ } ] }, + { + "name": "order_zero_is_legal_but_no_cursor_names_it", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": "c1" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c1", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, { "name": "a_mind_with_no_forks", "ordinal": 7, diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index df3a476..a6fbc80 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -188,6 +188,7 @@ "type": "string" }, "order": { + "description": "The question's rank: unique, 0-based, in (admission ordinal, key) order among all the Mind's\nquestions (see `Mind::desk_forks`; it deviates from \"the admission ordinal\" only in being unique).", "type": "integer", "format": "uint64", "minimum": 0