diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index fc74f4f..744ce7c 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -11,6 +11,8 @@ use std::path::PathBuf; +use serde::Serialize; + use cultnet_rs::{CultNetMessage, CultNetWireContract, encode_cultnet_message_to_vec, encode_frame}; use huginn_mind::MindRefusal; use huginn_mind::desk_wire::{ @@ -104,6 +106,106 @@ fn record() -> ForkRecord { } } +// ---- PROPOSED reader views (ruling: the door withholds account ids from readers who are not +// deciders, and returns counts). CultLib's card and desk_answer cannot carry this yet (card +// `deciders` has minItems 1 and no count key; `answerer` is a required non-null id), so these are +// fragments in the shape proposed to Libby (follow-up `cultlib-fork-card-reader-counts`), built from +// mirror types that exist only here. They are not Huginn's output until that contract lands and +// `cut-desk-feed-listing` builds it. ---- + +/// The two card keys the proposal changes, as a fragment: a decider gets the ids, anyone else `[]` +/// and the count. Not a card. +#[derive(Serialize)] +struct CardViewers { + deciders: Vec, + decider_count: u64, +} + +fn card_viewers(decider: bool) -> CardViewers { + let ids = vec![READER.to_string(), OTHER_DECIDER.to_string()]; + CardViewers { decider_count: ids.len() as u64, deciders: if decider { ids } else { Vec::new() } } +} + +/// `gamecult.desk_answer.v0` as proposed for a reader who is not a decider: `answerer` nil, key kept. +#[derive(Serialize)] +struct WithheldAnswer { + #[serde(rename = "schemaVersion")] + schema_version: AnswerSchema, + answer_id: String, + fork_id: String, + outcome: String, + of: Option, + retracts: Option, + code: Option, + options_hash: String, + choice: Option, + note: Option, + answered_at: String, + received_at: String, + claim_in_future: bool, + answerer: Option, + authority: DeskAuthority, + request_id: String, +} + +/// `gamecult.fork.record.v0` as proposed for a reader who is not a decider: every `answerer` nil and +/// `answerer_count`, the number of distinct answerers, in their place. +#[derive(Serialize)] +struct WithheldRecord { + #[serde(rename = "schemaVersion")] + schema_version: RecordSchema, + fork_id: String, + options_hash: String, + counted_answer_id: Option, + answerer_count: u64, + answers: Vec, +} + +fn withheld(record: ForkRecord) -> WithheldRecord { + let mut answerers: Vec<&str> = record.answers.iter().map(|answer| answer.answerer.as_str()).collect(); + answerers.sort_unstable(); + answerers.dedup(); + let answerer_count = answerers.len() as u64; + WithheldRecord { + schema_version: record.schema_version, + fork_id: record.fork_id, + options_hash: record.options_hash, + counted_answer_id: record.counted_answer_id, + answerer_count, + answers: record + .answers + .into_iter() + .map(|answer| WithheldAnswer { + schema_version: answer.schema_version, + answer_id: answer.answer_id, + fork_id: answer.fork_id, + outcome: answer.outcome, + of: answer.of, + retracts: answer.retracts, + code: answer.code, + options_hash: answer.options_hash, + choice: answer.choice, + note: answer.note, + answered_at: answer.answered_at, + received_at: answer.received_at, + claim_in_future: answer.claim_in_future, + answerer: None, + authority: answer.authority, + request_id: answer.request_id, + }) + .collect(), + } +} + +/// The proposed fragments by reader kind: (file stem, bytes). +fn reader_views() -> Vec<(&'static str, Vec)> { + vec![ + ("card-viewers-decider.synthetic", rmp_serde::to_vec_named(&card_viewers(true)).unwrap()), + ("card-viewers-non-decider.synthetic", rmp_serde::to_vec_named(&card_viewers(false)).unwrap()), + ("record-non-decider.proposed", rmp_serde::to_vec_named(&withheld(record())).unwrap()), + ] +} + /// The named MessagePack payload a CultNet operation message carries (base64 in the envelope). fn payload(message: &CultNetMessage) -> Vec { use base64::Engine; @@ -175,6 +277,10 @@ fn fixtures() -> Vec<(String, Vec)> { let whole = rmp_serde::to_vec_named(&record()).unwrap(); files.push(("record-whole.payload.msgpack".into(), whole.clone())); files.push(("record-whole.payload.hex".into(), hex(&whole).into_bytes())); + for (name, bytes) in reader_views() { + files.push((format!("{name}.payload.msgpack"), bytes.clone())); + files.push((format!("{name}.payload.hex"), hex(&bytes).into_bytes())); + } files } @@ -224,3 +330,27 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { assert!(!account_id(shape), "{shape} is not an account id"); } } + +fn holds(bytes: &[u8], text: &str) -> bool { + bytes.windows(text.len()).any(|window| window == text.as_bytes()) +} + +/// 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"); + } + 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"); +} diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/README.md b/crates/huginn-mind/tests/fixtures/desk-feed/README.md index b018191..0525883 100644 --- a/crates/huginn-mind/tests/fixtures/desk-feed/README.md +++ b/crates/huginn-mind/tests/fixtures/desk-feed/README.md @@ -35,7 +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 raw, as the door sends them (Bifrost masks for a reader who is not a decider) | +| `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 | 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/card-viewers-decider.synthetic.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex new file mode 100644 index 0000000..1624ae5 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex @@ -0,0 +1 @@ +82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack new file mode 100644 index 0000000..71b15b3 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack @@ -0,0 +1 @@ +‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d­decider_count \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex new file mode 100644 index 0000000..29a1257 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex @@ -0,0 +1 @@ +82a8646563696465727390ad646563696465725f636f756e7402 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack new file mode 100644 index 0000000..252f158 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack @@ -0,0 +1 @@ +‚¨deciders�­decider_count \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex new file mode 100644 index 0000000..4053399 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex @@ -0,0 +1 @@ +86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack new file mode 100644 index 0000000..d184479 Binary files /dev/null and b/crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack differ diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md index 9be005c..c6ab968 100644 --- a/docs/desk-feed-door.md +++ b/docs/desk-feed-door.md @@ -102,13 +102,36 @@ refuses any decider not in that form). That is the same grammar Huginn names for handle is never one of them. Bifrost enforces the grammar; Huginn's door does not yet: it reads these fields as `Short` (any text up to 200 bytes) and would accept `@x:server` or an email. A subject is stored as `answerer` on an answer, so a wrong value would enter the mind. Refusing every other -shape at decode is owed by `cut-desk-feed-listing.r4`. The test +shape at decode is owed by `cut-desk-feed-listing.r5`. The test `every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use synthetic UUIDs. -`card.deciders` echoes the call's decider list on every card to every reader. Bifrost masks -`answerer` for a reader who is not a decider (X3), but it must also mask `card.deciders`, or the -same account ids reach that reader through the card. +## Account ids by reader (planned; needs a CultLib change) + +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. + +- **On `main` today**, every reader gets the ids. `card.deciders` carries the call's decider list on + every card, and every answer carries its `answerer` id. This is not live yet: no receiver is + deployed. +- **Ruled (Persona, 2026-11-11; follow-up `chat-mind-20261111T150417226Z-t167`):** the door, not + 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 drafted + as `desk-read-visibility-withheld-ids`. +- **CultLib cannot carry this yet.** The card has 17 required keys in closed map-mode, 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. Response (`huginn.desk_response.v1`): @@ -174,16 +197,16 @@ every pinned response carries no entry. The entry-bearing fixtures (an open list 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 +## Trust boundary: what Huginn sends, 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. +- The peer-uid check is the whole authentication, so the socket must belong to Bifrost's uid alone. +- Today Huginn sends every record whole: every `answerer` id, every note and the card's + `deciders` (fixture `record-whole`). +- Once `cut-desk-feed-listing` and the CultLib change land, Huginn withholds the account ids from a + non-decider (above). Notes stay whole on the wire. - 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-listed`, `DeskNotes`). - Huginn masks nothing. + stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer + ids. ## Listing: which forks a reader sees (Huginn's; NOT live, see the status above)