Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
d5de551276 Desk feed pin: say which answerer nulls the bytes show, tie CULTLIB_SHA to the README and doc, note Bifrost consumers
Vendor CultLib's revision and retraction answer vectors (string answerer; no vector nulls them).
The doc and test now say two variants are pinned nulled by bytes (Counted, Refused) and all four
by ruling desk-reader-counts-shape. The sha test requires the fixture README and the doc to name
CULTLIB_SHA verbatim. Doc notes Bifrost heimdall/desk-card-repin (f977a43) re-pin and
DeskDocuments.cs JsonRequired answerer_count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:27:49 +00:00
18867ceea8 Desk feed fixtures: vendor CultLib's reader-count vectors at 3a51bbab, retire the mirror and synthetic fragments
Ten vectors from fork-card-vectors.json at CultLib 3a51bbabad244a56ec4521080530d88744001ce4
(#117 on #115), unedited: three cards, three answers, four entries (decider and non-decider twins).
Tests decode them with a decoder of their own and pin the header DE 00 12 (18 keys), record 86
with answerer_count fifth, decider_count after deciders, nullable answerer; Huginn's real types
are pinned as not yet carrying the shapes. docs/desk-feed-door.md states the pinned shapes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:19:08 +00:00
7ca49f13d2 Desk feed fixtures: four answer variants, one account answering twice
Soul on 30a0530: deleting dedup() in view() survived because three
answers came from three accounts, and Retraction was never in the
mirror record. The record now holds Counted, Revision, Refused and
Retraction by three accounts (four answers, answerer_count 3), and the
test asserts both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:00:56 +00:00
30a05309c2 Desk feed door: reader-count shapes as ruled (decider_count, nullable answerer, answerer_count)
Ruling desk-reader-counts-shape (Libby's CultLib reader-counts cut):
decider_count 1..64 right after deciders, deciders empty when withheld
or exactly decider_count ids; answerer nullable on all four variants and
null only in a non-decider's view; answerer_count over all stored
answers, refusals included; card 18 keys with raised_at, record 6.
Mirror fragments for both reader kinds and tests pinning those rules;
no CultLib bytes pinned until Libby posts her sha.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:53:32 +00:00
24 changed files with 365 additions and 170 deletions

View file

@ -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"));
}

View file

@ -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.

View file

@ -1 +0,0 @@
82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402

View file

@ -1 +0,0 @@
‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d­decider_count

View file

@ -1 +0,0 @@
82a8646563696465727390ad646563696465725f636f756e7402

View file

@ -1 +0,0 @@
‚¨deciders<EFBFBD>­decider_count

View 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.

View file

@ -1 +0,0 @@
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032

View file

@ -1 +1 @@
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034

View file

@ -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