diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index 399c82b..24b359c 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -3,15 +3,16 @@ //! are what the door speaks. `docs/desk-feed-door.md` is the contract they pin; the README in the //! fixtures directory lists them. //! -//! The card (`gamecult.fork.card.v0`) is in no fixture: it waits on CultLib's re-pinned card -//! vectors for `raised_at`. Every response pinned here carries no entry. +//! No fixture written here carries a card: Huginn's types do not carry `raised_at`, `decider_count`, +//! `answerer_count` or a nullable `answerer` yet. Those shapes are CultLib's own bytes, vendored at +//! one commit in `fixtures/desk-feed/cultlib-3a51bbab/` and checked below. //! //! `cargo test -p huginn-mind --test desk_feed_fixtures` compares every file with what the //! encoders produce now. `HUGINN_WRITE_DESK_FIXTURES=1` rewrites them instead. use std::path::PathBuf; -use serde::Serialize; +use sha2::{Digest, Sha256}; use cultnet_rs::{CultNetMessage, CultNetWireContract, encode_cultnet_message_to_vec, encode_frame}; use huginn_mind::MindRefusal; @@ -28,6 +29,8 @@ const RUNTIME: &str = "huginn-eureka"; /// other decider). Synthetic values. const READER: &str = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"; const OTHER_DECIDER: &str = "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"; +/// A member who answered but is not a decider: their answer is stored `Refused(not_a_decider)`. +const OUTSIDER: &str = "c3a1f2e4-7b6d-4e8f-9a0b-1c2d3e4f5a6b"; /// A question Eureka's mind holds (asked 2026-11-11). The answers, notes and hash in `record()` are /// synthetic: the hash is CultLib's sample digest, not this question's. const FORK: &str = "eureka-substrate:question:desk-listing-authority"; @@ -102,110 +105,18 @@ fn record() -> ForkRecord { answers: vec![ answer("n1", "Counted", None, READER, "The asker lists her own forks.", "2026-11-11T17:00:00Z"), answer("n2", "Revision", Some("n1"), OTHER_DECIDER, "Agreed.", "2026-11-11T17:05:00Z"), + ForkDeskAnswer { code: Some(huginn_mind::desk_wire::ForkAnswerCode::NotADecider), ..answer("n3", "Refused", None, OUTSIDER, "Me too.", "2026-11-11T17:06:00Z") }, + // A second answer by an account already counted: four answers, three answerers. + ForkDeskAnswer { + retracts: Some("n2".into()), + choice: None, + note: None, + ..answer("n4", "Retraction", None, OTHER_DECIDER, "", "2026-11-11T17:07:00Z") + }, ], } } -// ---- PROPOSED reader views (ruling: the door withholds account ids from readers who are not -// deciders, and returns counts). CultLib's card and desk_answer cannot carry this yet (card -// `deciders` has minItems 1 and no count key; `answerer` is a required non-null id), so these are -// fragments in the shape proposed to Libby (follow-up `cultlib-fork-card-reader-counts`), built from -// mirror types that exist only here. They are not Huginn's output until that contract lands and -// `cut-desk-feed-listing` builds it. ---- - -/// The two card keys the proposal changes, as a fragment: a decider gets the ids, anyone else `[]` -/// and the count. Not a card. -#[derive(Serialize)] -struct CardViewers { - deciders: Vec, - decider_count: u64, -} - -fn card_viewers(decider: bool) -> CardViewers { - let ids = vec![READER.to_string(), OTHER_DECIDER.to_string()]; - CardViewers { decider_count: ids.len() as u64, deciders: if decider { ids } else { Vec::new() } } -} - -/// `gamecult.desk_answer.v0` as proposed for a reader who is not a decider: `answerer` nil, key kept. -#[derive(Serialize)] -struct WithheldAnswer { - #[serde(rename = "schemaVersion")] - schema_version: AnswerSchema, - answer_id: String, - fork_id: String, - outcome: String, - of: Option, - retracts: Option, - code: Option, - options_hash: String, - choice: Option, - note: Option, - answered_at: String, - received_at: String, - claim_in_future: bool, - answerer: Option, - authority: DeskAuthority, - request_id: String, -} - -/// `gamecult.fork.record.v0` as proposed for a reader who is not a decider: every `answerer` nil and -/// `answerer_count`, the number of distinct answerers, in their place. -#[derive(Serialize)] -struct WithheldRecord { - #[serde(rename = "schemaVersion")] - schema_version: RecordSchema, - fork_id: String, - options_hash: String, - counted_answer_id: Option, - answerer_count: u64, - answers: Vec, -} - -fn withheld(record: ForkRecord) -> WithheldRecord { - let mut answerers: Vec<&str> = record.answers.iter().map(|answer| answer.answerer.as_str()).collect(); - answerers.sort_unstable(); - answerers.dedup(); - let answerer_count = answerers.len() as u64; - WithheldRecord { - schema_version: record.schema_version, - fork_id: record.fork_id, - options_hash: record.options_hash, - counted_answer_id: record.counted_answer_id, - answerer_count, - answers: record - .answers - .into_iter() - .map(|answer| WithheldAnswer { - schema_version: answer.schema_version, - answer_id: answer.answer_id, - fork_id: answer.fork_id, - outcome: answer.outcome, - of: answer.of, - retracts: answer.retracts, - code: answer.code, - options_hash: answer.options_hash, - choice: answer.choice, - note: answer.note, - answered_at: answer.answered_at, - received_at: answer.received_at, - claim_in_future: answer.claim_in_future, - answerer: None, - authority: answer.authority, - request_id: answer.request_id, - }) - .collect(), - } -} - -/// The proposed fragments by reader kind: (file stem, bytes). -fn reader_views() -> Vec<(&'static str, Vec)> { - vec![ - ("card-viewers-decider.synthetic", rmp_serde::to_vec_named(&card_viewers(true)).unwrap()), - ("card-viewers-non-decider.synthetic", rmp_serde::to_vec_named(&card_viewers(false)).unwrap()), - ("record-non-decider.proposed", rmp_serde::to_vec_named(&withheld(record())).unwrap()), - ] -} - /// The named MessagePack payload a CultNet operation message carries (base64 in the envelope). fn payload(message: &CultNetMessage) -> Vec { use base64::Engine; @@ -277,10 +188,6 @@ fn fixtures() -> Vec<(String, Vec)> { let whole = rmp_serde::to_vec_named(&record()).unwrap(); files.push(("record-whole.payload.msgpack".into(), whole.clone())); files.push(("record-whole.payload.hex".into(), hex(&whole).into_bytes())); - for (name, bytes) in reader_views() { - files.push((format!("{name}.payload.msgpack"), bytes.clone())); - files.push((format!("{name}.payload.hex"), hex(&bytes).into_bytes())); - } files } @@ -345,30 +252,275 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { } } + +// ---- CultLib's bytes: the reader-count shapes, pinned at one commit. ---- + +/// The CultLib commit the vendored vectors come from (#117 on #115). Re-pin: see the README beside them. +const CULTLIB_SHA: &str = "3a51bbabad244a56ec4521080530d88744001ce4"; + +/// Each vendored vector and the SHA-256 of its bytes. +const VENDORED: &[(&str, &str)] = &[ + ("card-operator-fork", "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5"), + ("card-raised-at-nil", "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c"), + ("card-withheld-deciders", "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7"), + ("answer-counted", "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6"), + ("answer-revision", "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315"), + ("answer-retraction", "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735"), + ("answer-withheld-answerer", "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427"), + ("answer-refused-stale", "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6"), + ("entry-open-counted", "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b"), + ("entry-withheld-open-counted", "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f"), + ("entry-closed-with-ruling-and-a-refusal", "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5"), + ("entry-withheld-closed-with-ruling-and-a-refusal", "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827"), +]; + +fn vendored(name: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/desk-feed/cultlib-3a51bbab").join(format!("{name}.msgpack")); + std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +/// A decoded MessagePack value that keeps map order. A small decoder of its own, so the key counts, +/// headers and positions below are read off the bytes and not off `rmp_serde`'s idea of the types. +#[derive(Debug, PartialEq)] +enum Value { + Nil, + Bool(bool), + Int(i64), + Str(String), + List(Vec), + Map(Vec<(String, Value)>), +} + +impl Value { + fn get(&self, key: &str) -> &Value { + let Value::Map(pairs) = self else { panic!("not a map") }; + &pairs.iter().find(|(name, _)| name == key).unwrap_or_else(|| panic!("no key {key}")).1 + } + fn keys(&self) -> Vec<&str> { + let Value::Map(pairs) = self else { panic!("not a map") }; + pairs.iter().map(|(name, _)| name.as_str()).collect() + } + fn list(&self) -> &[Value] { + let Value::List(items) = self else { panic!("not a list") }; + items + } + fn str(&self) -> &str { + let Value::Str(text) = self else { panic!("not a string: {self:?}") }; + text + } + fn int(&self) -> i64 { + let Value::Int(number) = self else { panic!("not an int: {self:?}") }; + *number + } +} + +fn decode(bytes: &[u8]) -> Value { + fn take<'a>(bytes: &'a [u8], at: &mut usize, n: usize) -> &'a [u8] { + let slice = &bytes[*at..*at + n]; + *at += n; + slice + } + fn be(slice: &[u8]) -> u64 { + slice.iter().fold(0, |acc, byte| (acc << 8) | u64::from(*byte)) + } + fn text(bytes: &[u8], at: &mut usize, n: usize) -> Value { + Value::Str(String::from_utf8(take(bytes, at, n).to_vec()).unwrap()) + } + fn items(bytes: &[u8], at: &mut usize, n: usize) -> Vec { + (0..n).map(|_| value(bytes, at)).collect() + } + fn pairs(bytes: &[u8], at: &mut usize, n: usize) -> Value { + Value::Map((0..n).map(|_| (value(bytes, at).str().to_string(), value(bytes, at))).collect()) + } + fn value(bytes: &[u8], at: &mut usize) -> Value { + let tag = take(bytes, at, 1)[0]; + match tag { + 0x00..=0x7f => Value::Int(i64::from(tag)), + 0x80..=0x8f => pairs(bytes, at, usize::from(tag & 0x0f)), + 0x90..=0x9f => Value::List(items(bytes, at, usize::from(tag & 0x0f))), + 0xa0..=0xbf => text(bytes, at, usize::from(tag & 0x1f)), + 0xc0 => Value::Nil, + 0xc2 => Value::Bool(false), + 0xc3 => Value::Bool(true), + 0xcc => Value::Int(be(take(bytes, at, 1)) as i64), + 0xcd => Value::Int(be(take(bytes, at, 2)) as i64), + 0xce => Value::Int(be(take(bytes, at, 4)) as i64), + 0xd9 => { + let n = be(take(bytes, at, 1)) as usize; + text(bytes, at, n) + } + 0xda => { + let n = be(take(bytes, at, 2)) as usize; + text(bytes, at, n) + } + 0xdc => { + let n = be(take(bytes, at, 2)) as usize; + Value::List(items(bytes, at, n)) + } + 0xde => { + let n = be(take(bytes, at, 2)) as usize; + pairs(bytes, at, n) + } + other => panic!("MessagePack tag {other:#04x} at {} is not in this decoder", *at - 1), + } + } + let mut at = 0; + let decoded = value(bytes, &mut at); + assert_eq!(at, bytes.len(), "trailing bytes"); + decoded +} + +const CARD_KEYS: [&str; 18] = [ + "schemaVersion", "fork_id", "title", "context", "options", "recommendation", "depends", "leans", "flag", "raised_in", "raised_at", "deciders", + "decider_count", "status", "ruling", "ruled_from", "order", "options_hash", +]; +const RECORD_KEYS: [&str; 6] = ["schemaVersion", "fork_id", "options_hash", "counted_answer_id", "answerer_count", "answers"]; + +#[test] +fn the_vendored_cultlib_vectors_are_the_pinned_bytes() { + assert_eq!(CULTLIB_SHA.len(), 40); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/desk-feed/cultlib-3a51bbab"); + let docs = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/desk-feed-door.md"); + for path in [dir.join("README.md"), docs] { + let text = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + assert!(text.contains(CULTLIB_SHA), "{} does not name the pinned sha {CULTLIB_SHA}", path.display()); + } + assert!(dir.to_str().unwrap().ends_with(&format!("cultlib-{}", &CULTLIB_SHA[..8])), "the directory is named for the pin"); + for (name, sha) in VENDORED { + let digest: String = Sha256::digest(vendored(name)).iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!(&digest, sha, "{name} is not CultLib {CULTLIB_SHA}'s bytes"); + } +} + +/// The card: header `DE 00 12` (map16, 18 entries: `schemaVersion` and 17 fields), the keys in this +/// order, `raised_at` a string or nil, `decider_count` 1..64 right after `deciders`, and `deciders` empty +/// (withheld) or exactly `decider_count` account ids. +fn check_card(card: &Value, withheld: bool) { + assert_eq!(card.keys(), CARD_KEYS); + assert!(matches!(card.get("raised_at"), Value::Nil | Value::Str(_))); + let count = card.get("decider_count").int(); + assert!((1..=64).contains(&count)); + let ids = card.get("deciders").list(); + assert_eq!(ids.is_empty(), withheld); + assert!(ids.is_empty() || ids.len() as i64 == count); + assert!(ids.iter().all(|id| account_id(id.str())), "deciders are lowercase D-format UUIDs"); + assert_eq!(card.get("options_hash").str().len(), 64); +} + +#[test] +fn the_cards_are_eighteen_keys_with_raised_at_and_decider_count() { + for name in ["card-operator-fork", "card-raised-at-nil", "card-withheld-deciders"] { + let bytes = vendored(name); + assert_eq!(bytes[..3], [0xde, 0x00, 0x12], "{name}: header"); + check_card(&decode(&bytes), name == "card-withheld-deciders"); + } + assert!(matches!(decode(&vendored("card-operator-fork")).get("raised_at"), Value::Str(_))); + assert_eq!(decode(&vendored("card-raised-at-nil")).get("raised_at"), &Value::Nil); + assert_eq!(decode(&vendored("card-withheld-deciders")).get("decider_count").int(), 2); +} + +/// `answerer` is a string or nil on every answer; `Value::Nil` only when `named` is false. The bytes +/// null it on `Counted` and `Refused` only; nullable on all four variants is the ruling +/// `desk-reader-counts-shape`, and CultLib has no vector with a `Revision` or `Retraction` nulled. +fn check_answer(answer: &Value, named: bool) { + assert_eq!(answer.keys().len(), 16); + assert_eq!(answer.keys()[13], "answerer"); + match answer.get("answerer") { + Value::Str(id) => assert!(named && account_id(id), "a named answerer is a lowercase D-format UUID"), + Value::Nil => assert!(!named), + other => panic!("answerer is {other:?}"), + } +} + +#[test] +fn the_answers_keep_sixteen_keys_and_answerer_is_a_string_or_nil() { + for (name, named) in [("answer-counted", true), ("answer-revision", true), ("answer-retraction", true), ("answer-refused-stale", true), ("answer-withheld-answerer", false)] { + let bytes = vendored(name); + assert_eq!(bytes[..3], [0xde, 0x00, 0x10], "{name}: header"); + check_answer(&decode(&bytes), named); + } + for (name, outcome) in [("answer-counted", "Counted"), ("answer-revision", "Revision"), ("answer-retraction", "Retraction"), ("answer-refused-stale", "Refused")] { + assert_eq!(decode(&vendored(name)).get("outcome").str(), outcome); + } +} + +/// The entry is `83` and its record `86`, `answerer_count` the fifth key. In a decider's view the count +/// is the number of distinct answerer ids over every stored answer, refusals included; in a +/// non-decider's view no answerer is named and the count is between 1 and the number of answers (0 for +/// none). A non-decider entry holds none of its twin's account ids anywhere in its bytes. +#[test] +fn the_entries_are_83_over_a_record_of_86_with_answerer_count_before_answers() { + for (decider, non_decider) in [("entry-open-counted", "entry-withheld-open-counted"), ("entry-closed-with-ruling-and-a-refusal", "entry-withheld-closed-with-ruling-and-a-refusal")] { + let (decider_bytes, non_decider_bytes) = (vendored(decider), vendored(non_decider)); + let mut ids = Vec::new(); + for (bytes, named) in [(&decider_bytes, true), (&non_decider_bytes, false)] { + assert_eq!(bytes[0], 0x83, "entry header"); + let entry = decode(bytes); + assert_eq!(entry.keys(), ["schemaVersion", "card", "record"]); + check_card(entry.get("card"), !named); + let record = entry.get("record"); + assert_eq!(record.keys(), RECORD_KEYS); + let answers = record.list_of_answers(); + let count = record.get("answerer_count").int(); + answers.iter().for_each(|answer| check_answer(answer, named)); + if named { + let mut distinct: Vec<&str> = answers.iter().map(|answer| answer.get("answerer").str()).collect(); + distinct.sort_unstable(); + distinct.dedup(); + assert_eq!(count, distinct.len() as i64, "{decider}: distinct answerers"); + ids.extend(distinct.iter().map(|id| id.to_string())); + ids.extend(entry.get("card").get("deciders").list().iter().map(|id| id.str().to_string())); + } else if answers.is_empty() { + assert_eq!(count, 0); + } else { + assert!((1..=answers.len() as i64).contains(&count)); + } + } + assert!(!ids.is_empty()); + assert!(ids.iter().all(|id| !holds(&non_decider_bytes, id)), "{non_decider} carries an account id"); + assert!(ids.iter().all(|id| holds(&decider_bytes, id)), "{decider} names every id"); + // The twins agree on everything the view does not move. + let (a, b) = (decode(&decider_bytes), decode(&non_decider_bytes)); + assert_eq!(a.get("card").get("options_hash"), b.get("card").get("options_hash")); + assert_eq!(a.get("card").get("decider_count"), b.get("card").get("decider_count")); + assert_eq!(a.get("record").get("answerer_count"), b.get("record").get("answerer_count")); + } + // A refusal is an answer and its answerer counts: the closed fork's two answers are two accounts. + let closed = decode(&vendored("entry-closed-with-ruling-and-a-refusal")); + assert!(closed.get("record").list_of_answers().iter().any(|answer| answer.get("outcome").str() == "Refused")); + assert_eq!(closed.get("record").get("answerer_count").int(), 2); +} + fn holds(bytes: &[u8], text: &str) -> bool { bytes.windows(text.len()).any(|window| window == text.as_bytes()) } -/// This test pins MIRROR TYPES ONLY (`CardViewers`, `WithheldRecord`), the shapes proposed to CultLib. -/// It does not exercise Huginn's projection, which does not withhold ids yet. The tests on real list and -/// read responses land with `cut-desk-feed-listing` (r6). -/// -/// A reader who is not a decider gets counts and no account id at all: neither UUID appears -/// anywhere in its fragments' bytes. A decider gets every id. (Proposed shapes; the build cut pins -/// the same on Huginn's real list and read responses.) -#[test] -fn a_non_decider_gets_counts_and_no_ids_a_decider_gets_ids() { - let views = reader_views(); - let bytes = |name: &str| views.iter().find(|(stem, _)| *stem == name).unwrap().1.clone(); - for name in ["card-viewers-non-decider.synthetic", "record-non-decider.proposed"] { - let view = bytes(name); - assert!(!holds(&view, READER) && !holds(&view, OTHER_DECIDER), "{name} carries an account id"); +impl Value { + fn list_of_answers(&self) -> &[Value] { + self.get("answers").list() } - let non_decider = card_viewers(false); - assert_eq!((non_decider.deciders.len(), non_decider.decider_count), (0, 2)); - assert_eq!(withheld(record()).answerer_count, 2); - let decider = bytes("card-viewers-decider.synthetic"); - assert!(holds(&decider, READER) && holds(&decider, OTHER_DECIDER)); - let whole = rmp_serde::to_vec_named(&record()).unwrap(); - assert!(holds(&whole, READER) && holds(&whole, OTHER_DECIDER), "a decider's record keeps the answerer ids"); +} + +/// Huginn's real types against CultLib's bytes. A decider's answers already decode (the answer keeps +/// its 16 keys). The card and the record do not: `ForkCard` has no `raised_at` or `decider_count`, +/// `ForkRecord` no `answerer_count`, and `ForkDeskAnswer.answerer` is a non-null string, and each type +/// refuses unknown keys. This pins the gap `cut-desk-feed-listing` closes; when it does, these +/// refusals become successful decodes of the same bytes and this test changes with them. +#[test] +fn huginn_types_do_not_yet_carry_the_pinned_shapes() { + use huginn_mind::desk_wire::ForkCard; + for name in ["answer-counted", "answer-refused-stale"] { + rmp_serde::from_slice::(&vendored(name)).unwrap_or_else(|error| panic!("{name}: {error}")); + } + fn refused(bytes: &[u8]) -> String { + rmp_serde::from_slice::(bytes).err().expect("the real type refuses these bytes").to_string() + } + assert!(refused::(&vendored("card-operator-fork")).contains("raised_at")); + assert!(refused::(&vendored("card-withheld-deciders")).contains("raised_at")); + assert!(refused::(&vendored("answer-withheld-answerer")).contains("string")); + // The record is the entry's last key: its bytes run from the `86` after the key to the end. + let entry = vendored("entry-open-counted"); + let key = [&[0xa6u8][..], b"record", &[0x86]].concat(); + let at = entry.windows(key.len()).position(|window| window == key).expect("the record key") + key.len() - 1; + assert!(refused::(&entry[at..]).contains("answerer_count")); } diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/README.md b/crates/huginn-mind/tests/fixtures/desk-feed/README.md index 0525883..4b98b4f 100644 --- a/crates/huginn-mind/tests/fixtures/desk-feed/README.md +++ b/crates/huginn-mind/tests/fixtures/desk-feed/README.md @@ -35,10 +35,10 @@ lands (see `docs/desk-feed-door.md`). | `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) | | `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) | | `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) | -| `record-whole` | fragment | `gamecult.fork.record.v0` with a counted answer and a revision, answerer ids and notes whole: what the door sends today, and a decider's view | -| `record-non-decider.proposed` | fragment | PROPOSED non-decider record: every `answerer` nil, `answerer_count` 2. Needs CultLib change `cultlib-fork-card-reader-counts`; not Huginn output yet | -| `card-viewers-decider.synthetic` | fragment | SYNTHETIC: only `{deciders: [two ids], decider_count: 2}`. Not a card | -| `card-viewers-non-decider.synthetic` | fragment | SYNTHETIC: only `{deciders: [], decider_count: 2}`. Not a card | +| `record-whole` | fragment | `gamecult.fork.record.v0` with all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole: what the door sends today (5 keys, no `answerer_count`) | -No case carries a card (`gamecult.fork.card.v0`). The card's bytes wait on CultLib's re-pinned card -vectors for `raised_at`, so no entry-bearing list page or read is pinned yet. +The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are +CultLib's own bytes, vendored unedited in `cultlib-3a51bbab/` (see its README for the pinned commit and +each vector). They are not Huginn's output: Huginn's types do not carry those shapes yet. +`cut-desk-feed-listing` and `cut-desk-feed-raised-at` replace them with entry-bearing fixtures the +encoders write, after decoding these bytes into the real types. diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex deleted file mode 100644 index 1624ae5..0000000 --- a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex +++ /dev/null @@ -1 +0,0 @@ -82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack deleted file mode 100644 index 71b15b3..0000000 --- a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack +++ /dev/null @@ -1 +0,0 @@ -‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d­decider_count \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex deleted file mode 100644 index 29a1257..0000000 --- a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex +++ /dev/null @@ -1 +0,0 @@ -82a8646563696465727390ad646563696465725f636f756e7402 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack deleted file mode 100644 index 252f158..0000000 --- a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack +++ /dev/null @@ -1 +0,0 @@ -‚¨deciders�­decider_count \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/README.md b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/README.md new file mode 100644 index 0000000..a3203a7 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/README.md @@ -0,0 +1,26 @@ +# CultLib desk-feed vectors, vendored + +Source: `GameCult/CultLib` at commit `3a51bbabad244a56ec4521080530d88744001ce4` (#117, on +`af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is +the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them +from Huginn's encoders: Huginn's own types do not carry these shapes yet (see +`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in +`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`. + +| file | upstream vector | what it pins | +|---|---|---| +| `card-operator-fork` | `card.valid` `operator_fork` | 18 keys (`DE 00 12`), `raised_at` a string, a decider's `deciders` and `decider_count` 1 | +| `card-raised-at-nil` | `card.valid` `raised_at_nil` | `raised_at` nil, key kept | +| `card-withheld-deciders` | `card.valid` `withheld_deciders` | empty `deciders`, `decider_count` 2 | +| `answer-counted` | `deskAnswer.valid` `counted` | 16 keys (`DE 00 10`), `answerer` a string | +| `answer-revision` | `deskAnswer.valid` `revision` | a `Revision`, `answerer` a string (no CultLib vector nulls a Revision) | +| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string (no CultLib vector nulls a Retraction) | +| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil | +| `answer-refused-stale` | `deskAnswer.valid` `refused_stale` | a `Refused` answer, `answerer` a string | +| `entry-open-counted` | `transport.entry.valid` `open_counted` | decider view: entry `83`, record `86`, `answerer_count` 1 | +| `entry-withheld-open-counted` | `transport.entry.valid` `withheld_open_counted` | non-decider twin: `deciders` empty, `answerer` nil, count 1 | +| `entry-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `closed_with_ruling_and_a_refusal` | decider view, a refusal among the answers, `answerer_count` 2 | +| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 | + +The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own +(not `rmp_serde`), and then tries Huginn's real types. diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-counted.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-counted.msgpack new file mode 100644 index 0000000..332a979 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-counted.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-refused-stale.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-refused-stale.msgpack new file mode 100644 index 0000000..75fc7fd Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-refused-stale.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-retraction.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-retraction.msgpack new file mode 100644 index 0000000..7f2fb22 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-retraction.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-revision.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-revision.msgpack new file mode 100644 index 0000000..aaff18f Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-revision.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-withheld-answerer.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-withheld-answerer.msgpack new file mode 100644 index 0000000..5074793 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-withheld-answerer.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-operator-fork.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-operator-fork.msgpack new file mode 100644 index 0000000..325e668 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-operator-fork.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-raised-at-nil.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-raised-at-nil.msgpack new file mode 100644 index 0000000..a5e848e Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-raised-at-nil.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-withheld-deciders.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-withheld-deciders.msgpack new file mode 100644 index 0000000..922be4e Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-withheld-deciders.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-closed-with-ruling-and-a-refusal.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-closed-with-ruling-and-a-refusal.msgpack new file mode 100644 index 0000000..93e6b2d Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-closed-with-ruling-and-a-refusal.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-open-counted.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-open-counted.msgpack new file mode 100644 index 0000000..eb86045 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-open-counted.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-closed-with-ruling-and-a-refusal.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-closed-with-ruling-and-a-refusal.msgpack new file mode 100644 index 0000000..93d5db4 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-closed-with-ruling-and-a-refusal.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-open-counted.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-open-counted.msgpack new file mode 100644 index 0000000..2d91b27 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-open-counted.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex deleted file mode 100644 index 4053399..0000000 --- a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex +++ /dev/null @@ -1 +0,0 @@ -86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack deleted file mode 100644 index d184479..0000000 Binary files a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack and /dev/null differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex index aa5f793..5a76c83 100644 --- a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex +++ b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex @@ -1 +1 @@ -85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 +85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack index 1c8b4ed..78e2ed3 100644 Binary files a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack and b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack differ diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md index 87e6432..a930172 100644 --- a/docs/desk-feed-door.md +++ b/docs/desk-feed-door.md @@ -4,7 +4,8 @@ Status, 2026-11-11: the list and read wire described here is on `main` (`DeskReq `DeskRequest::Fork`, `DeskResponse::Forks`, `DeskResponse::Fork`; `crates/huginn-mind/src/desk_wire.rs` and `desk_wire/reads.rs`; the socket is `crates/huginn-daemon/src/desk_door.rs`). No running daemon binds a desk socket yet (follow-up `eureka-substrate:follow_up:desk-door-ops-root-run`). Two parts -are planned and not built: `raised_at` on the card, and the listing gate that decides which forks a +are planned and not built: the card's `raised_at`, `decider_count` and the record's `answerer_count` +and nullable `answerer` (the shapes pinned below), and the listing gate that decides which forks a reader sees. They are cut specs `cut-desk-feed-raised-at` and `cut-desk-feed-listing` in the `eureka-substrate` campaign. @@ -18,7 +19,8 @@ go live on a Mind that holds private questions. This page is the wire contract for the consumer, Bifrost's `UnixSocketDeskDoor` (Bifrost PR #67, `docs/desk-feed-design.md`). The golden bytes are in `crates/huginn-mind/tests/fixtures/desk-feed/`. Huginn's own encoders write them (`crates/huginn-mind/tests/desk_feed_fixtures.rs`), so a decoder -that reads them reads the door. The document shapes inside the payload (`gamecult.fork.list.v0`, +that reads them reads the door. The reader-count shapes are CultLib's bytes, vendored in +`cultlib-3a51bbab/` (below). The document shapes inside the payload (`gamecult.fork.list.v0`, `gamecult.fork.entry.v0`, `gamecult.fork.card.v0`, `gamecult.fork.record.v0`, `gamecult.desk_answer.v0`, `gamecult.fork.refusal.v0`) are CultLib's (`contracts/cultnet/governance/fork-card.md`) and are not restated here. @@ -106,7 +108,7 @@ shape at decode is owed by `cut-desk-feed-listing.r6`. The test `every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use synthetic UUIDs. -## Account ids by reader (planned; needs a CultLib change) +## Account ids by reader (shape pinned in CultLib; Huginn side planned) A reader is a **decider** when the call's `subject` is in the call's `deciders`, compared byte for byte; both values are in the account-id format above. Any other reader is a non-decider. @@ -118,20 +120,42 @@ byte; both values are in the account-id format above. Any other reader is a non- Bifrost, withholds account ids from a non-decider and returns a count instead. Bifrost cannot do it, because the card is opaque to it. A decider sees the ids. The superseding ruling is in force: `eureka-substrate:ruling:desk-read-visibility-withheld-ids`. -- **CultLib cannot carry this yet.** The card has 16 required keys in closed map-mode (17 once `raised_at` lands), and - `deciders` must hold at least one item. `answerer` is a required, non-null id. The proposal to - Libby (follow-up `cultlib-fork-card-reader-counts`): - - the card gains a required `decider_count` (an integer). A non-decider gets `deciders: []`; a - decider gets the ids. The count is the same for both. - - `answerer` becomes nullable: nil for a non-decider, with the key kept, as `note` is stripped. - - the record gains a required `answerer_count`, the number of distinct answerers. -- **Proposed fixtures**, built from mirror types that exist only in the test: - - `card-viewers-decider.synthetic` and `card-viewers-non-decider.synthetic` hold only the two card - keys the proposal changes. They are not cards. - - `record-non-decider.proposed` is a whole record as the proposal shapes it. - - The test `a_non_decider_gets_counts_and_no_ids_a_decider_gets_ids` checks that neither account id - appears anywhere in a non-decider's bytes. `record-whole` is the decider's view. +- **The shape**, pinned to CultLib commit `3a51bbabad244a56ec4521080530d88744001ce4` (#117 "Fork card: + decider_count, answerer_count, nullable answerer (reader views)", on `af914253` #115 `raised_at`; + ruling `eureka-substrate:ruling:desk-reader-counts-shape`). The bytes are CultLib's own vectors + (`contracts/cultnet/governance/fork-card-vectors.json`, `messagePackHex`), vendored unedited in + `crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/` (its README lists each file and + its vector name). Read off those bytes: + - **Card:** 18 keys, header `DE 00 12` (map16): `schemaVersion` plus 17 fields, in this order: + `fork_id, title, context, options, recommendation, depends, leans, flag, raised_in, raised_at, + deciders, decider_count, status, ruling, ruled_from, order, options_hash`. `raised_at` is an + RFC3339 string or nil, always written. `decider_count` is an integer from 1 to 64 right after + `deciders`. An empty `deciders` means the ids are withheld from this reader, never "no + deciders"; a non-empty list holds exactly `decider_count` ids. + - **Record:** 6 keys, header `86`: `schemaVersion, fork_id, options_hash, counted_answer_id, + answerer_count, answers`, so `answerer_count` is the fifth key, right before `answers`. It is + the number of distinct answerers over ALL the fork's stored answers, refusals included, and is + the same for both readers. In a non-decider's view every answerer is nil, and the count is then + between 1 and the number of answers (0 with no answers). + - **Answer:** `gamecult.desk_answer.v0` keeps its 16 keys (header `DE 00 10`); `answerer` is a + string or nil, nil only in a non-decider's view, with the key kept. The bytes pin a nil `answerer` + on two variants, `Counted` (`answer-withheld-answerer`) and `Refused` (the withheld entries). That + it is nullable on all four variants (`Counted`, `Revision`, `Retraction`, `Refused`) is the ruling + `eureka-substrate:ruling:desk-reader-counts-shape`, not a vector: CultLib's `revision` and + `retraction` vectors carry a string `answerer` and no CultLib vector has either nulled. Stored + answers always carry it. + - **Entry:** header `83` (`schemaVersion, card, record`). The view is read off `deciders`: a + non-empty `deciders` names every answerer, an empty one names none. + - `options_hash` does not change (still five members). + - **Consumers:** Bifrost branch `heimdall/desk-card-repin` (`f977a43`) vendors Huginn's desk + fixtures from `79d954c` and needs a re-pin after this PR. Bifrost's `DeskDocuments.cs` marks + `answerer_count` `JsonRequired`, so its merge must wait for `cut-desk-feed-listing`: today's door + does not send the field. + - Huginn's real types do not carry these shapes yet: `ForkCard` has no `raised_at` or + `decider_count`, `ForkRecord` no `answerer_count`, `ForkDeskAnswer.answerer` is a non-null + string, and each type refuses unknown keys. The test + `huginn_types_do_not_yet_carry_the_pinned_shapes` pins that gap, and + `cut-desk-feed-listing` closes it by decoding these same bytes into the real types. Response (`huginn.desk_response.v1`): @@ -179,23 +203,21 @@ affected, because each Mind has its own socket and the client asks per Mind: An empty list is never served in place of a failure. `open_count: 0` means zero open forks. -## Card fields (pending CultLib's re-pinned card vectors) +## Card fields -`raised_at` is ruled by CultLib's steward (2026-11-11). It is a required card key whose value is an -RFC3339 string or nil, which takes the card from 16 to 17 keys in closed map-mode. Nil means unknown. -A string is the question's first-admission time, which never moves. It is outside `options_hash`, -which keeps its five members, so the hash vectors do not change. Huginn derives it from the -question's admission: the `committed_at` of the receipt that first admitted the question, or for an -imported question the source mind's `committed_at` (`imported_from.committed_at`). A value that does not -parse as RFC3339 is served as nil. One known limit: after a campaign has moved twice (A to B to C), -C sees B's admission time, because an import quotes its source's receipt and B's receipt is B's +`raised_at` is a required card key whose value is an RFC3339 string or nil (CultLib #115, `af914253`). +Nil means unknown. A string is the question's first-admission time, which never moves. It is outside +`options_hash`, which keeps its five members, so the hash vectors do not change. Huginn derives it +from the question's admission: the `committed_at` of the receipt that first admitted the question, or +for an imported question the source mind's `committed_at` (`imported_from.committed_at`). A value that +does not parse as RFC3339 is served as nil. One known limit: after a campaign has moved twice (A to B +to C), C sees B's admission time, because an import quotes its source's receipt and B's receipt is B's own (follow-up `desk-raised-at-import-chain`). -**No fixture here carries card bytes.** The card's key order and hex wait on the CultLib commit that -re-pins the card vectors with `raised_at`. Until then the card is opaque to these fixtures, and -every pinned response carries no entry. The entry-bearing fixtures (an open list page and a read -with `raised_at` as a string and as nil) are added by `cut-desk-feed-raised-at` against that -CultLib commit. +The card's key order and bytes are pinned by the vendored CultLib vectors (above): `card-operator-fork` +(`raised_at` a string), `card-raised-at-nil` and `card-withheld-deciders`. The fixtures Huginn's own +encoders write carry no entry; entry-bearing list and read fixtures from Huginn's output are added by +`cut-desk-feed-raised-at` and `cut-desk-feed-listing`. ## Trust boundary: what Huginn sends, what Bifrost does