diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs new file mode 100644 index 0000000..e366af9 --- /dev/null +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -0,0 +1,184 @@ +//! The desk feed's golden fixtures: the bytes Bifrost writes to a Mind's desk socket for a list or +//! a read, and the bytes Huginn writes back, built here with Huginn's own encoders so the files +//! 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. +//! +//! `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 cultnet_rs::{CultNetMessage, CultNetWireContract, encode_cultnet_message_to_vec, encode_frame}; +use huginn_mind::MindRefusal; +use huginn_mind::desk_wire::{ + AnswerSchema, DeskRequest, DeskResponse, ForkCursor, ForkDeskAnswer, ForkLimit, ForkList, ForkRecord, ForkRefusal, ForkRefusalCode, + ListSchema, ListStatus, RecordSchema, +}; +use huginn_mind::envelope::{OperationFailure, encode_desk_failure, encode_desk_request, encode_desk_response}; +use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug}; + +const RUNTIME: &str = "huginn-eureka"; +const FORK: &str = "eureka-substrate:question:desk-fork-audience"; + +fn s(text: &str) -> Short { + Short(text.into()) +} + +fn forks(status: Option, cursor: Option, limit: u8) -> DeskRequest { + DeskRequest::Forks { + mind: Slug("eureka".into()), + subject: s("7"), + deciders: vec![s("7"), s("12")], + status, + cursor: cursor.and_then(ForkCursor::new), + limit: ForkLimit::new(limit).unwrap(), + } +} + +fn read() -> DeskRequest { + DeskRequest::Fork { mind: Slug("eureka".into()), subject: s("7"), deciders: vec![s("7"), s("12")], fork_id: FORK.into() } +} + +fn list(open_count: u64, recorded_count: u64) -> DeskResponse { + DeskResponse::Forks { + list: ForkList { + schema_version: ListSchema::V0, + mind: "eureka".into(), + stamp: "s10190".into(), + open_count, + recorded_count, + forks: Vec::new(), + next_cursor: None, + }, + as_of: 10190, + } +} + +fn refusal(code: ForkRefusalCode, error: &str) -> DeskResponse { + DeskResponse::NotStored(ForkRefusal { schema_version: huginn_mind::desk_wire::RefusalSchema::V0, code, error: error.into() }) +} + +fn answer(id: &str, outcome: &str, of: Option<&str>, answerer: &str, note: &str, received_at: &str) -> ForkDeskAnswer { + ForkDeskAnswer { + schema_version: AnswerSchema::V0, + answer_id: id.into(), + fork_id: FORK.into(), + outcome: outcome.into(), + of: of.map(str::to_string), + retracts: None, + code: None, + options_hash: "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48".into(), + choice: Some("campaign-listing".into()), + note: Some(note.into()), + answered_at: received_at.into(), + received_at: received_at.into(), + claim_in_future: false, + answerer: answerer.into(), + authority: DeskAuthority::Member, + request_id: format!("00000000-0000-4000-8000-00000000000{}", &id[1..]), + } +} + +/// A fork's record with a counted answer and a decider's revision, answerer ids raw: the door sends +/// them raw and Bifrost masks them for a reader who is not a decider. +fn record() -> ForkRecord { + ForkRecord { + schema_version: RecordSchema::V0, + fork_id: FORK.into(), + options_hash: "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48".into(), + counted_answer_id: Some("n1".into()), + answers: vec![ + answer("n1", "Counted", None, "7", "List by campaign.", "2026-11-11T17:00:00Z"), + answer("n2", "Revision", Some("n1"), "12", "Agreed, campaign grain.", "2026-11-11T17:05:00Z"), + ], + } +} + +/// The named MessagePack payload a CultNet operation message carries (base64 in the envelope). +fn payload(message: &CultNetMessage) -> Vec { + use base64::Engine; + let (CultNetMessage::OperationRequest { payload, .. } | CultNetMessage::OperationResponse { payload, .. }) = message else { + panic!("an operation message") + }; + base64::engine::general_purpose::STANDARD.decode(payload).unwrap() +} + +fn frame(message: &CultNetMessage) -> Vec { + encode_frame(&encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap()).unwrap() +} + +fn hex(bytes: &[u8]) -> String { + let mut text: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + text.push('\n'); + text +} + +/// Every fixture: its file stem and its bytes. A message gives `.frame.bin`, `.frame.hex` and, +/// for an operation message, `.payload.msgpack` and `.payload.hex`; a bare payload gives the last two. +fn fixtures() -> Vec<(String, Vec)> { + let mut files = Vec::new(); + let mut message = |name: &str, message: CultNetMessage| { + let framed = frame(&message); + files.push((format!("{name}.frame.bin"), framed.clone())); + files.push((format!("{name}.frame.hex"), hex(&framed).into_bytes())); + if matches!(message, CultNetMessage::OperationRequest { .. } | CultNetMessage::OperationResponse { .. }) { + let inner = payload(&message); + files.push((format!("{name}.payload.msgpack"), inner.clone())); + files.push((format!("{name}.payload.hex"), hex(&inner).into_bytes())); + } + }; + message("list-open.request", encode_desk_request("m-1", &forks(Some(ListStatus::Open), None, 50), None).unwrap()); + message("list-open-cursor.request", encode_desk_request("m-2", &forks(Some(ListStatus::Open), Some(42), 50), None).unwrap()); + message("list-counts.request", encode_desk_request("m-3", &forks(Some(ListStatus::Open), None, 0), None).unwrap()); + message("read.request", encode_desk_request("m-4", &read(), None).unwrap()); + message("list-empty.response", encode_desk_response("m-1", "forks", &list(0, 0), RUNTIME).unwrap()); + message("list-counts.response", encode_desk_response("m-3", "forks", &list(3, 5), RUNTIME).unwrap()); + message( + "read-unknown.response", + encode_desk_response("m-4", "fork", &refusal(ForkRefusalCode::ForkUnknown, "fork_unknown: no such fork"), RUNTIME).unwrap(), + ); + message( + "list-too-large.response", + encode_desk_response( + "m-1", + "forks", + &refusal(ForkRefusalCode::ForkTooLarge, &format!("fork_too_large: fork {FORK} at order 41 is past the page budget by itself; continue with cursor c41")), + RUNTIME, + ) + .unwrap(), + ); + message( + "unavailable.response", + encode_desk_response("m-1", "forks", &DeskResponse::Refused(MindRefusal::Unavailable { detail: "the mind store is not readable".into() }), RUNTIME).unwrap(), + ); + message( + "failure.response", + encode_desk_failure("m-1", "forks", &OperationFailure { code: "payload-not-a-request".into(), message: "field Forks.limit: out-of-range".into() }, RUNTIME), + ); + message("error.response", CultNetMessage::Error { error: "huginn.desk refuses a message_id over 256 bytes".into(), code: None, details: None }); + 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())); + files +} + +#[test] +fn desk_feed_fixtures_are_what_the_encoders_write() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/desk-feed"); + let write = std::env::var_os("HUGINN_WRITE_DESK_FIXTURES").is_some(); + if write { + std::fs::create_dir_all(&dir).unwrap(); + } + for (name, bytes) in fixtures() { + let path = dir.join(&name); + if write { + std::fs::write(&path, &bytes).unwrap(); + } else { + let held = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + assert!(held == bytes, "{name} differs from what the encoders write now"); + } + } +} diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/README.md b/crates/huginn-mind/tests/fixtures/desk-feed/README.md new file mode 100644 index 0000000..ff8dca9 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/README.md @@ -0,0 +1,33 @@ +# Desk feed golden fixtures + +The bytes on a Mind's desk socket for a list or a read. The contract is `docs/desk-feed-door.md`. +`crates/huginn-mind/tests/desk_feed_fixtures.rs` writes these files with Huginn's own encoders and +checks them on every `cargo test -p huginn-mind --test desk_feed_fixtures`. Set +`HUGINN_WRITE_DESK_FIXTURES=1` to rewrite them. Do not edit them by hand. + +Each case has up to four files: + +- `.frame.bin` and `.frame.hex`: the whole frame on the socket (u32 big-endian length, + then the CultNet message). +- `.payload.msgpack` and `.payload.hex`: the base64-decoded `payload` of an operation + message (named MessagePack). The `record-*` cases are bare payload fragments with no frame. + +Hex is lowercase with one trailing newline. + +| case | direction | content | +|---|---|---| +| `list-open.request` | Bifrost to Huginn | `Forks`, status open, no cursor, limit 50 | +| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 | +| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) | +| `read.request` | Bifrost to Huginn | `Fork` for one fork id | +| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` | +| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` | +| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) | +| `list-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large` naming the cursor to continue (Bifrost: 422) | +| `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 raw, as the door sends them (Bifrost masks for a reader who is not a decider) | + +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. diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.bin new file mode 100644 index 0000000..e3f3cb2 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.hex new file mode 100644 index 0000000..fffbf7c --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/error.response.frame.hex @@ -0,0 +1 @@ +0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.bin new file mode 100644 index 0000000..3937de0 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.hex new file mode 100644 index 0000000..dd4c2ba --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.frame.hex @@ -0,0 +1 @@ +000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.hex new file mode 100644 index 0000000..815c014 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.hex @@ -0,0 +1 @@ +82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.msgpack new file mode 100644 index 0000000..e985a65 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/failure.response.payload.msgpack @@ -0,0 +1 @@ +‚¤codeµpayload-not-a-request§message¿field Forks.limit: out-of-range \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.bin new file mode 100644 index 0000000..b5c855d Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.hex new file mode 100644 index 0000000..d0554ad --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.frame.hex @@ -0,0 +1 @@ +0000011287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9606761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644b45337147526c59326c6b5a584a7a6b7145336f6a4579706e4e30595852316336527663475675706d4e31636e4e766373436c62476c7461585141af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.hex new file mode 100644 index 0000000..60cb15a --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a6737461747573a46f70656ea6637572736f72c0a56c696d697400 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.msgpack new file mode 100644 index 0000000..931ad8d Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.request.payload.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.bin new file mode 100644 index 0000000..586b679 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.hex new file mode 100644 index 0000000..26d91d8 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.frame.hex @@ -0,0 +1 @@ +0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.hex new file mode 100644 index 0000000..47af395 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.msgpack new file mode 100644 index 0000000..a040d33 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-counts.response.payload.msgpack @@ -0,0 +1 @@ +�¥Forks‚¤list‡­schemaVersionµgamecult.fork.list.v0¤mind¦eureka¥stamp¦s10190ªopen_count®recorded_count¥forks�«next_cursorÀ¥as_ofÍ'Î \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.bin new file mode 100644 index 0000000..5184d42 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.hex new file mode 100644 index 0000000..df49d00 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.frame.hex @@ -0,0 +1 @@ +0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.hex new file mode 100644 index 0000000..3a4ef1e --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.msgpack new file mode 100644 index 0000000..d6dd07b Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-empty.response.payload.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.bin new file mode 100644 index 0000000..55963c3 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.hex new file mode 100644 index 0000000..74bfc3c --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.frame.hex @@ -0,0 +1 @@ +0000011687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9646761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644b45337147526c59326c6b5a584a7a6b7145336f6a4579706e4e30595852316336527663475675706d4e31636e4e7663714e6a4e444b6c62476c7461585179af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.hex new file mode 100644 index 0000000..cd0c5b0 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.msgpack new file mode 100644 index 0000000..d9b0991 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open-cursor.request.payload.msgpack @@ -0,0 +1 @@ +�¥Forks†¤mind¦eureka§subject¡7¨deciders’¡7¢12¦status¤open¦cursor£c42¥limit2 \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.bin new file mode 100644 index 0000000..0da51f3 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.hex new file mode 100644 index 0000000..304b63b --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.frame.hex @@ -0,0 +1 @@ +0000011287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9606761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644b45337147526c59326c6b5a584a7a6b7145336f6a4579706e4e30595852316336527663475675706d4e31636e4e766373436c62476c7461585179af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.hex new file mode 100644 index 0000000..e8564d6 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a6737461747573a46f70656ea6637572736f72c0a56c696d697432 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.msgpack new file mode 100644 index 0000000..90f32b7 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-open.request.payload.msgpack @@ -0,0 +1 @@ +�¥Forks†¤mind¦eureka§subject¡7¨deciders’¡7¢12¦status¤open¦cursorÀ¥limit2 \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.bin new file mode 100644 index 0000000..623b115 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.hex new file mode 100644 index 0000000..60505ec --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.frame.hex @@ -0,0 +1 @@ +0000020389a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da012067616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a69575a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179316d62334a724c5746315a476c6c626d4e6c49474630494739795a4756794944517849476c7a49484268633351676447686c494842685a325567596e566b5a32563049474a3549476c30633256735a6a73675932397564476c756457556764326c306143426a64584a7a62334967597a5178af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.hex new file mode 100644 index 0000000..5b6bc62 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.hex @@ -0,0 +1 @@ +81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d989666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d666f726b2d61756469656e6365206174206f7264657220343120697320706173742074686520706167652062756467657420627920697473656c663b20636f6e74696e7565207769746820637572736f7220633431 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.msgpack new file mode 100644 index 0000000..f294566 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/list-too-large.response.payload.msgpack @@ -0,0 +1 @@ +�©NotStoredƒ­schemaVersion¸gamecult.fork.refusal.v0¤code®fork_too_large¥errorÙ‰fork_too_large: fork eureka-substrate:question:desk-fork-audience at order 41 is past the page budget by itself; continue with cursor c41 \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.bin new file mode 100644 index 0000000..4018637 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.hex new file mode 100644 index 0000000..4299eb0 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.frame.hex @@ -0,0 +1 @@ +0000016989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d98867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757735a6d397961313931626d74756233647570575679636d3979756d5a76636d746664573572626d3933626a6f67626d38676333566a6143426d62334a72af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.hex new file mode 100644 index 0000000..2634d13 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.hex @@ -0,0 +1 @@ +81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ac666f726b5f756e6b6e6f776ea56572726f72ba666f726b5f756e6b6e6f776e3a206e6f207375636820666f726b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.msgpack new file mode 100644 index 0000000..fbd6335 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-unknown.response.payload.msgpack @@ -0,0 +1 @@ +�©NotStoredƒ­schemaVersion¸gamecult.fork.refusal.v0¤code¬fork_unknown¥errorºfork_unknown: no such fork \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.bin new file mode 100644 index 0000000..8860f10 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.hex new file mode 100644 index 0000000..cfb4b1f --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.frame.hex @@ -0,0 +1 @@ +0000013587a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d9846761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e306f54656f5a47566a6157526c636e4f536f5465694d544b6e5a6d3979613139705a4e6b735a5856795a5774684c584e31596e4e30636d46305a5470786457567a64476c76626a706b5a584e724c575a76636d73745958566b615756755932553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.hex new file mode 100644 index 0000000..8785434 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.hex @@ -0,0 +1 @@ +81a4466f726b84a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a7666f726b5f6964d92c657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d666f726b2d61756469656e6365 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.msgpack new file mode 100644 index 0000000..38683f4 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read.request.payload.msgpack @@ -0,0 +1 @@ +�¤Fork„¤mind¦eureka§subject¡7¨deciders’¡7¢12§fork_idÙ,eureka-substrate:question:desk-fork-audience \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex new file mode 100644 index 0000000..619d820 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.hex @@ -0,0 +1 @@ +85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d92c657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d666f726b2d61756469656e6365ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d92c657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d666f726b2d61756469656e6365a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365b063616d706169676e2d6c697374696e67a46e6f7465b14c6973742062792063616d706169676e2eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572a137a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d92c657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d666f726b2d61756469656e6365a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365b063616d706169676e2d6c697374696e67a46e6f7465b74167726565642c2063616d706169676e20677261696e2eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572a23132a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack new file mode 100644 index 0000000..cc2e81a Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/record-whole.payload.msgpack differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.bin new file mode 100644 index 0000000..bbb82db Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.bin differ diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.hex new file mode 100644 index 0000000..c2e1375 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.frame.hex @@ -0,0 +1 @@ +0000013689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d954676164535a575a316332566b67617456626d463259576c7359574a735a59476d5a47563059576c73766e526f5a5342746157356b49484e3062334a6c49476c7a49473576644342795a57466b59574a735a513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.hex new file mode 100644 index 0000000..a1e1be5 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.hex @@ -0,0 +1 @@ +81a75265667573656481ab556e617661696c61626c6581a664657461696cbe746865206d696e642073746f7265206973206e6f74207265616461626c65 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.msgpack new file mode 100644 index 0000000..cbfe1bc --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/unavailable.response.payload.msgpack @@ -0,0 +1 @@ +�§Refused�«Unavailable�¦detail¾the mind store is not readable \ No newline at end of file diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md new file mode 100644 index 0000000..98ad947 --- /dev/null +++ b/docs/desk-feed-door.md @@ -0,0 +1,145 @@ +# Desk feed door: the list and read wire between Bifrost and a Mind's Huginn + +Status, 2026-11-11: the list and read wire described here is on `main` (`DeskRequest::Forks`, +`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 +reader sees. They are cut specs `cut-desk-feed-raised-at` and `cut-desk-feed-listing` in the +`eureka-substrate` campaign. + +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`, +`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. + +## One socket per Mind + +- Each Mind's Huginn daemon binds its own socket, given by `--desk-socket ` and + `--desk-peer-uid ` (both or neither). The ops plan names `/run/huginn-desk/.sock`, mode + 0660, group `huginn-desk`. +- The kernel's peer credential is checked before a byte is read. Only the configured uid (Bifrost's) + is answered; any other peer is closed with nothing read or written. That check is the whole + authentication: Bifrost vouches for `subject` and `deciders`, and Huginn never sees the member's + token. +- Each connection carries one request frame and gets one reply frame, all within 1 second. A + retry is a new connection. Huginn answers each read from its live image at one snapshot. It keeps + no cache, and Bifrost needs none: the list's `stamp` (`s`) is the change signal. +- Huginn serves only its own Mind. A request whose `mind` is another instance is refused + (`Refused(...)`). + +## Framing and envelope + +``` +frame = u32 big-endian length || message +message = MessagePack map, CultNet schema v0 (cultnet-rs encode_cultnet_message_to_vec) +payload = message["payload"], standard base64 of named MessagePack (rmp_serde::to_vec_named) +``` + +- Request message: `schemaVersion` `cultnet.operation_request.v0`, `serviceId` `huginn.desk`, + `operation` `forks` (list) or `fork` (read), `payloadSchema` `huginn.desk_request.v1`, + `payloadEncoding` `messagepack-base64`, `messageId` (at most 256 bytes, echoed back). +- Response message: `cultnet.operation_response.v0`, the same `serviceId`, `operation` and + `messageId`, `status` `accepted` or `rejected`, and `sourceRuntimeId` (informational: do not + compare it). The `payloadSchema` is `huginn.desk_response.v1`, or + `gamecult.cultnet.operation_failure.v1` when the envelope or payload did not decode. +- Envelope keys are written in sorted order and absent optionals are omitted. Decode by key, not + by position. +- Frame bound: 65536 bytes each way. A reply past it is refused by name, never truncated. + +## Payloads + +Rust's externally tagged enums: a one-key map from the variant name to its fields. Every field is +written; `null` where absent. + +Request (`huginn.desk_request.v1`): + +| operation | payload | +|---|---| +| `forks` | `{"Forks": {mind, subject, deciders, status, cursor, limit}}`. `status` is `"open"`, `"recorded"` or nil (both); `cursor` is `"c"` (N from 1) or nil; `limit` 0..50, where 0 returns counts and stamp only. | +| `fork` | `{"Fork": {mind, subject, deciders, fork_id}}` | + +`deciders` is the Mind's admin-set list for this call: 1 to 64 distinct ids, sent on every call. +Huginn keeps no copy. `subject` is the verified Bifrost account id of the reader. The reads carry +no `authority`, and a payload that names it is refused whole. + +Response (`huginn.desk_response.v1`): + +| variant | meaning | Bifrost answers | +|---|---|---| +| `{"Forks": {list, as_of}}` | one page, `list` is `gamecult.fork.list.v0` | 200 | +| `{"Fork": {entry, as_of}}` | one fork, `entry` is `gamecult.fork.entry.v0` | 200 | +| `{"NotStored": refusal}` | `gamecult.fork.refusal.v0`: `fork_unknown`, or `fork_too_large` with the cursor to continue | 404 / 422 | +| `{"Refused": {"": {...}}}` | a Mind refusal (store fault, wrong instance, a response too large) | 503 `unavailable` | + +## Failure means 503 for that Mind only + +Each of these gives the request 503 `unavailable` with `Retry-After`. Only the Mind addressed is +affected, because each Mind has its own socket and the client asks per Mind: + +- the socket is absent, or refuses the connection (`ENOENT`, `ECONNREFUSED`), or the peer closes it + without a reply frame; +- no whole reply frame arrives within the deadline; +- the reply is a CultNet `Error` message (`cultnet.error.v0`, fixture `error.response`), an + operation failure (fixture `failure.response`), or a `Refused` payload (fixture + `unavailable.response`). + +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) + +`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`). + +**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. + +## Trust boundary: what Huginn sends raw, what Bifrost does + +- Huginn sends every record whole over the peer-checked socket: every answer's `answerer` id and + every note, unmasked (fixture `record-whole`). The peer-uid check means only Bifrost's uid reads + them. A second reader of the socket would leak answerer ids and notes, so the socket must belong + to Bifrost's uid alone. +- Bifrost owns three things: the per-request membership check, the `desk.read` token, and + masking on what the door returns. It masks answerer ids for a reader who is not a decider (X3, + Heimdall 2026-11-11) and strips notes (ruling `desk-read-visibility-whole-record`, `DeskNotes`). + Huginn masks nothing. + +## Listing: which forks a reader sees (Huginn's; planned) + +Huginn owns audience and visibility, at its door. The card has no audience key (CultLib), and +Bifrost's feed applies no fork filter. + +- **Default: not listed.** A fork (a showable question) appears in a list or read for a reader who + is not on the call's `deciders` only while a `desk_listing` document for it is in force. Otherwise + the list omits it, it counts in neither `open_count` nor `recorded_count`, and a read of it + answers `fork_unknown`, the same bytes as a fork that does not exist. The counts and the cursor + are computed over the visible set, which is why the gate is Huginn's: a filter applied after + paging would make both wrong. +- **Deciders** see every showable fork whether or not it is listed (recommended in question + `eureka-substrate:question:desk-unlisted-to-deciders`), because they must see a fork to answer it. + The door compares the call's `subject` with its `deciders` to tell. The wire does not change. +- **The listing record** is planned kind `desk_listing`, key `:desk_listing:`, + fields `fork` (the question's id) and `reason` (one line: why members may read it). It is + admitted on the Mind's own door, so its receipt records who listed it and when. Unlisting is a + `Withdrawn` resolution on it, and relisting withdraws that resolution, which reopens it. Each + flip is a document with its own receipt, so the history is `huginn history `. No flag + is stored anywhere else. +- **Who may list or unlist** is question `eureka-substrate:question:desk-listing-authority`. The + recommended answer is the asking Mind herself, at her Self. +- **Open for Heimdall:** if Bifrost wants any part of the listing (an operator override at the + desk, a per-Mind default, a reason shown to members), that is his decision to raise. Nothing in + this plan puts listing state in Bifrost. + +The fixture `list-empty.response` is also what a reader who is not a decider gets from a Mind +with nothing listed.