Desk feed door: reader-count shapes pinned to CultLib 3a51bbab (vendored vectors) #58
24 changed files with 365 additions and 170 deletions
|
|
@ -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<String>,
|
||||
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<String>,
|
||||
retracts: Option<String>,
|
||||
code: Option<huginn_mind::desk_wire::ForkAnswerCode>,
|
||||
options_hash: String,
|
||||
choice: Option<String>,
|
||||
note: Option<String>,
|
||||
answered_at: String,
|
||||
received_at: String,
|
||||
claim_in_future: bool,
|
||||
answerer: Option<String>,
|
||||
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<String>,
|
||||
answerer_count: u64,
|
||||
answers: Vec<WithheldAnswer>,
|
||||
}
|
||||
|
||||
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<u8>)> {
|
||||
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<u8> {
|
||||
use base64::Engine;
|
||||
|
|
@ -277,10 +188,6 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
|
|||
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<u8> {
|
||||
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<Value>),
|
||||
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<Value> {
|
||||
(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::<ForkDeskAnswer>(&vendored(name)).unwrap_or_else(|error| panic!("{name}: {error}"));
|
||||
}
|
||||
fn refused<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> String {
|
||||
rmp_serde::from_slice::<T>(bytes).err().expect("the real type refuses these bytes").to_string()
|
||||
}
|
||||
assert!(refused::<ForkCard>(&vendored("card-operator-fork")).contains("raised_at"));
|
||||
assert!(refused::<ForkCard>(&vendored("card-withheld-deciders")).contains("raised_at"));
|
||||
assert!(refused::<ForkDeskAnswer>(&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::<ForkRecord>(&entry[at..]).contains("answerer_count"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402
|
||||
|
|
@ -1 +0,0 @@
|
|||
‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4ddecider_count
|
||||
|
|
@ -1 +0,0 @@
|
|||
82a8646563696465727390ad646563696465725f636f756e7402
|
||||
|
|
@ -1 +0,0 @@
|
|||
‚¨deciders<EFBFBD>decider_count
|
||||
26
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/README.md
vendored
Normal file
26
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/README.md
vendored
Normal file
|
|
@ -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.
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-counted.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-counted.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-refused-stale.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-refused-stale.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-retraction.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-retraction.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-revision.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-revision.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-withheld-answerer.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/answer-withheld-answerer.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-operator-fork.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-operator-fork.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-raised-at-nil.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-raised-at-nil.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-withheld-deciders.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/card-withheld-deciders.msgpack
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-open-counted.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-open-counted.msgpack
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-open-counted.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/cultlib-3a51bbab/entry-withheld-open-counted.msgpack
vendored
Normal file
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032
|
||||
Binary file not shown.
|
|
@ -1 +1 @@
|
|||
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032
|
||||
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue