From da90d7e74e156773eb69382290060efa1021bd48 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 17:35:49 +0000 Subject: [PATCH 1/4] Desk feed door: account ids are lowercase D-format UUIDs; fixtures and a test pin it subject, deciders and answerer are Bifrost UserAccount ids (lowercase D-format, as Bifrost's validator requires and as Huginn names RequestId). The fixtures used "7"/"12"; they now carry synthetic UUIDs, and a test pins the grammar and refuses Matrix ids, emails and handles. The doc states that Huginn's door does not refuse other shapes yet (owed by cut-desk-feed-listing.r4) and that card.deciders echoes the ids. Co-Authored-By: Claude Sonnet 5.5 --- .../huginn-mind/tests/desk_feed_fixtures.rs | 44 ++++++++++++++++-- .../desk-feed/list-counts.request.frame.bin | Bin 278 -> 422 bytes .../desk-feed/list-counts.request.frame.hex | 2 +- .../desk-feed/list-counts.request.payload.hex | 2 +- .../list-counts.request.payload.msgpack | Bin 72 -> 179 bytes .../list-open-cursor.request.frame.bin | Bin 282 -> 426 bytes .../list-open-cursor.request.frame.hex | 2 +- .../list-open-cursor.request.payload.hex | 2 +- .../list-open-cursor.request.payload.msgpack | 2 +- .../desk-feed/list-open.request.frame.bin | Bin 278 -> 422 bytes .../desk-feed/list-open.request.frame.hex | 2 +- .../desk-feed/list-open.request.payload.hex | 2 +- .../list-open.request.payload.msgpack | 2 +- .../fixtures/desk-feed/read.request.frame.bin | Bin 317 -> 462 bytes .../fixtures/desk-feed/read.request.frame.hex | 2 +- .../desk-feed/read.request.payload.hex | 2 +- .../desk-feed/read.request.payload.msgpack | 2 +- .../desk-feed/record-whole.payload.hex | 2 +- .../desk-feed/record-whole.payload.msgpack | Bin 1071 -> 1142 bytes docs/desk-feed-door.md | 16 +++++++ 20 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index 9c6b64a..fc74f4f 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -21,6 +21,11 @@ use huginn_mind::envelope::{OperationFailure, encode_desk_failure, encode_desk_r use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug}; const RUNTIME: &str = "huginn-eureka"; +/// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format +/// (`DeskEndpoints.cs` `accountId.Value.ToString("D")`; `StartupConfigurationValidator` refuses any +/// other decider). Synthetic values. +const READER: &str = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"; +const OTHER_DECIDER: &str = "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"; /// 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"; @@ -32,8 +37,8 @@ fn s(text: &str) -> Short { fn forks(status: Option, cursor: Option, limit: u8) -> DeskRequest { DeskRequest::Forks { mind: Slug("eureka".into()), - subject: s("7"), - deciders: vec![s("7"), s("12")], + subject: s(READER), + deciders: vec![s(READER), s(OTHER_DECIDER)], status, cursor: cursor.and_then(ForkCursor::new), limit: ForkLimit::new(limit).unwrap(), @@ -41,7 +46,7 @@ fn forks(status: Option, cursor: Option, limit: u8) -> DeskRequ } fn read() -> DeskRequest { - DeskRequest::Fork { mind: Slug("eureka".into()), subject: s("7"), deciders: vec![s("7"), s("12")], fork_id: FORK.into() } + DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(READER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() } } fn list(open_count: u64, recorded_count: u64) -> DeskResponse { @@ -93,8 +98,8 @@ fn record() -> ForkRecord { options_hash: "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48".into(), counted_answer_id: Some("n1".into()), answers: vec![ - answer("n1", "Counted", None, "7", "The asker lists her own forks.", "2026-11-11T17:00:00Z"), - answer("n2", "Revision", Some("n1"), "12", "Agreed.", "2026-11-11T17:05:00Z"), + 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"), ], } } @@ -190,3 +195,32 @@ fn desk_feed_fixtures_are_what_the_encoders_write() { } } } + +/// The one grammar for `subject`, every `deciders` entry and every `answerer`: a lowercase +/// D-format UUID (8-4-4-4-12 lowercase hex), the grammar Huginn already names for `RequestId` +/// (`eureka-pipeline` `uuid_text`). A Matrix id, an email or a handle never fits it. +fn account_id(text: &str) -> bool { + text.len() == 36 + && text.bytes().enumerate().all(|(at, byte)| match at { + 8 | 13 | 18 | 23 => byte == b'-', + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }) +} + +/// Every account id the fixtures carry is a Bifrost account id, never a Matrix id, email or +/// handle. The door does not refuse other shapes yet (`Short`, any 200 bytes); that refusal is owed +/// by `cut-desk-feed-listing.r4`, and this pins the grammar it must enforce. +#[test] +fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { + for request in [forks(Some(ListStatus::Open), None, 50), read()] { + let subject = match &request { + DeskRequest::Forks { subject, .. } | DeskRequest::Fork { subject, .. } | DeskRequest::Answer { subject, .. } => subject, + }; + assert!(account_id(&subject.0), "subject"); + assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders"); + } + assert!(record().answers.iter().all(|answer| account_id(&answer.answerer)), "answerer"); + for shape in ["@heimdall:gamecult.org", "meta@gamecult.org", "heimdall", "7", "5F0C2B1E-8A4D-4C3B-9E7F-1A2B3C4D5E6F", "{5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f}", ""] { + assert!(!account_id(shape), "{shape} is not an account id"); + } +} 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 index b5c855dcea675190691239996c47ab8ca6542722..87fae5add38ff5ea47e85766ef0aa333fe1f2913 100644 GIT binary patch delta 225 zcmbQnw2YaBfq`++L>3Lk4-<7&>;1B`{lX*5BdfeJeZxab{M}s*qMQv3BCGr|eL`|_ z{EQ+?e8XKUBeVRn{M-W#qe7x`3*CcqB8_sgqawVjvP($UZxkF@9+{Ps?PnBPY2BlX&Yk?UvVY806j2DCUhH`zESBgG@Es=(c{JSyBQ cH_|w)GTF$ovfMkYI5FI?AjQ$qc4BWQ0Q9a)=>Px# delta 81 zcmZ3+JdKHkfq_wIB8vuN!bDwFYj0QMLieDYNTZzWs0gpB>_S)L{4CeXf;>Ni$cP}r kWV4{MWcRSrf?Pkt#E3x0$+C={0K5VjdjJ3c 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 index d0554ad..f11745c 100644 --- 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 @@ -1 +1 @@ -0000011287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9606761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644b45337147526c59326c6b5a584a7a6b7145336f6a4579706e4e30595852316336527663475675706d4e31636e4e766373436c62476c7461585141af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b +000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b 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 index 60cb15a..804b47e 100644 --- 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 @@ -1 +1 @@ -81a5466f726b7386a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a6737461747573a46f70656ea6637572736f72c0a56c696d697400 +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400 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 index 931ad8d5551e6dfba06304c62e3f992180624569..d79df5f0eb938b431b98771da27ce0eb714928da 100644 GIT binary patch literal 179 zcma*f%MpSw5CBk%;kq*c5c)Nej&v85%7sE=epYEnbpekeatFxwNP_zv#fyoXp(J G5(WSg=M`@N 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 index 55963c3bdf9dcd8269226338428bd4bbb6aedb39..f2479d544ad0275f44632f9291a560f0725df468 100644 GIT binary patch delta 229 zcmb8oO%B086ae7*xr)R_GM1qb-@ zJ?rmA({XCAs@bl6s!r>UN}-^F?Ccndb-oCECOyW;;&INkMxZ~V2_S)L{4CeXf;>Ni$cP}r oWV4{MWcRSrf?Pkt#E8Jk$%>4f04v=a8vp3Lk4-<7&>;1B`{lX*5BdfeJeZxab{M}s*qMQv3BCGr|eL`|_ z{EQ+?e8XKUBeVRn{M-W#qe7x`3*CcqB8_sgqawVjvP($UZxkF@9+{Ps?PnBPY2BlX&Yk?UvVY806j2DCUhH`zESBgG@Es=(c{JSyBQ cH_|w)GTF$ovfMkYI5FI?AjQSgc4BWQ0QBff^Z)<= delta 81 zcmZ3+JdKHkfq_wIB8vuN!bDwFYj0QMLieDYNTZzWs0gpB>_S)L{4CeXf;>Ni$cP}r kWV4{MWcRSrf?Pkt#E8Jk$+C={0KFO+vj6}9 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 index 304b63b..c351ca8 100644 --- 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 @@ -1 +1 @@ -0000011287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9606761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644b45337147526c59326c6b5a584a7a6b7145336f6a4579706e4e30595852316336527663475675706d4e31636e4e766373436c62476c7461585179af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b +000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b 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 index e8564d6..2006332 100644 --- 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 @@ -1 +1 @@ -81a5466f726b7386a46d696e64a6657572656b61a77375626a656374a137a8646563696465727392a137a23132a6737461747573a46f70656ea6637572736f72c0a56c696d697432 +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432 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 index 90f32b7..0b18a68 100644 --- 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 @@ -1 +1 @@ -�¥Forks†¤mind¦eureka§subject¡7¨deciders’¡7¢12¦status¤open¦cursorÀ¥limit2 \ No newline at end of file +�¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2 \ 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 index 9b114db76c59855abedb6ebb62de897f6d8a8adb..181317a6cb0e7dda51352ad73cb4d043b449c1a2 100644 GIT binary patch delta 278 zcmb7;%~gXi9Kc)KAsoQhlLtt=c<@4tu}H`xL?F2Za)9s$`joy`yf6ckIDrn}!2z5` zR&apd$LH10X1iLgzvu04^Si#DZ*L})#ICZQrS0*e66a0|-h_f%ijD)zUa+d2ixfdk z5p~odg{alNBB|9Ghq7bI|M@}%2d`#XjFpI{Diq36>kko;F59~mC9x9<07fL1Le){W zgr7}Y;NUe!&>-P~$pg^HA4;6oCFY~dIk4{0Re9v-?EcK!We&X!1%Y=%;H*ZsGCA)T T$H_c34<9EzLa&YA&0o_Wjiy}I delta 150 zcmX@dyqAfEfq~I-B8wW+&5nt>D)spxsrgavVOfdcK{?5J{=qF zL6l#1c~nGLWmI@chEIf_VPu}4L2|B}K~zXVMM`*BRf>B~SyEO(c2tC4kxzIsKfhB@BaUr_ xOjrQLGb}YJSaR$IOQ=!L->^PMFJ1G7my)U{h~g}uh74p1wv;tdL9wYz& delta 25 hcmeyyv7Tdt3lq~q^UYaIOBk6K85&K_Vsd0O0swN#2gLvY diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md index 8f233ec..9be005c 100644 --- a/docs/desk-feed-door.md +++ b/docs/desk-feed-door.md @@ -94,6 +94,22 @@ Request (`huginn.desk_request.v1`): 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. +**Account ids.** `subject`, every `deciders` entry and every stored `answerer` are Bifrost account ids: +`UserAccount.Id` in lowercase D-format, 8-4-4-4-12 lowercase hex (Bifrost renders it in +`DeskEndpoints.cs` with `accountId.Value.ToString("D")`, and `StartupConfigurationValidator.cs` +refuses any decider not in that form). That is the same grammar Huginn names for `RequestId` +(`uuid_text`). These ids are opaque random UUIDs. A Matrix id, an email, a display name or any other +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 +`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. + Response (`huginn.desk_response.v1`): | variant | meaning | Bifrost answers | -- 2.47.3 From d08b8880ffb9894de64352229787ede9186d758e Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 17:38:52 +0000 Subject: [PATCH 2/4] Desk feed door: the door withholds account ids from non-deciders (proposed shapes, CultLib change owed) Persona's ruling (follow-up chat-mind-20261111T150417226Z-t167): a reader is a decider when subject is in the call's deciders; any other reader gets no decider or answerer ids, only counts, and Huginn does it because the card is opaque to Bifrost. CultLib's closed card (deciders minItems 1, no count) and non-null answerer cannot carry it yet, so the doc names the proposal to Libby and the fixtures add clearly synthetic card fragments and a proposed non-decider record, with a test that no account id appears in a non-decider's bytes and a decider keeps them. Co-Authored-By: Claude Sonnet 5.5 --- .../huginn-mind/tests/desk_feed_fixtures.rs | 130 ++++++++++++++++++ .../tests/fixtures/desk-feed/README.md | 5 +- ...card-viewers-decider.synthetic.payload.hex | 1 + ...-viewers-decider.synthetic.payload.msgpack | 1 + ...-viewers-non-decider.synthetic.payload.hex | 1 + ...wers-non-decider.synthetic.payload.msgpack | 1 + .../record-non-decider.proposed.payload.hex | 1 + ...ecord-non-decider.proposed.payload.msgpack | Bin 0 -> 1084 bytes docs/desk-feed-door.md | 47 +++++-- 9 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.msgpack create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack 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 0000000000000000000000000000000000000000..d1844792604e05ecfa6fc789c084223d9f192c9b GIT binary patch literal 1084 zcmc(eO-chX6vxFAc!Vz8PMOTNT`8{JNO9q2NRv06&`zRBro{_*0FO{gr?q|{2=1hK zi*7uNNn6E*A5axT$mE}7!h8SU%lWk;ITZK^q+)_Uv~dBX?CN?ZWT!5H2ubTbx3V&2 zbnZeap@SV&wv?7wL*ujrrOmGm3aaCDnGwA0;8N#8GJSR>PVBBqbFA|DZcKN=j3hA) zeJ}9b2%!uD6cR)s^J44=K^&pD)ru0&53?WwA7{vSFjNZFkii`*i|4 sBzCZJ)zDhtyq|*`A3~4WhhWH=!ult6)m~cy&@Ul{-$7a&_)AFM3sQ-X#Q*>R literal 0 HcmV?d00001 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) -- 2.47.3 From ea4193e0e4d3a6e6165dec0db14ffd0a384acf5d Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 17:42:26 +0000 Subject: [PATCH 3/4] Desk feed door: card has 16 keys (17 with raised_at); grammar test kills length and dash mutants Soul on d08b888: CultLib main's card requires 16 keys, not 17. The account-id grammar test now rejects 36 hex digits with no dash, a 37-character id and a dash off position. The reader-view test says it pins mirror types only. Co-Authored-By: Claude Sonnet 5.5 --- .../huginn-mind/tests/desk_feed_fixtures.rs | 20 ++++++++++++++++++- docs/desk-feed-door.md | 4 ++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index 744ce7c..4f79237 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -326,7 +326,21 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders"); } assert!(record().answers.iter().all(|answer| account_id(&answer.answerer)), "answerer"); - for shape in ["@heimdall:gamecult.org", "meta@gamecult.org", "heimdall", "7", "5F0C2B1E-8A4D-4C3B-9E7F-1A2B3C4D5E6F", "{5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f}", ""] { + for shape in [ + "@heimdall:gamecult.org", + "meta@gamecult.org", + "heimdall", + "7", + "5F0C2B1E-8A4D-4C3B-9E7F-1A2B3C4D5E6F", + "{5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f}", + "", + // 36 hex digits and no dash: the length alone does not make an id. + "5f0c2b1e08a4d04c3b09e7f01a2b3c4d5e6f", + // 37 characters: a valid id with one more digit. + "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f0", + // A dash moved off its position. + "5f0c2b1e8-a4d-4c3b-9e7f-1a2b3c4d5e6f", + ] { assert!(!account_id(shape), "{shape} is not an account id"); } } @@ -335,6 +349,10 @@ 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.) diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md index c6ab968..e440feb 100644 --- a/docs/desk-feed-door.md +++ b/docs/desk-feed-door.md @@ -102,7 +102,7 @@ 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.r5`. The test +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. @@ -118,7 +118,7 @@ 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 drafted as `desk-read-visibility-withheld-ids`. -- **CultLib cannot carry this yet.** The card has 17 required keys in closed map-mode, and +- **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 -- 2.47.3 From 0115915b37ff83b7e878f704db084ca4b7b9e61b Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 17:42:34 +0000 Subject: [PATCH 4/4] Desk feed fixtures: the account-id refusal is owed by cut-desk-feed-listing.r6 Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/tests/desk_feed_fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index 4f79237..399c82b 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -315,7 +315,7 @@ fn account_id(text: &str) -> bool { /// Every account id the fixtures carry is a Bifrost account id, never a Matrix id, email or /// handle. The door does not refuse other shapes yet (`Short`, any 200 bytes); that refusal is owed -/// by `cut-desk-feed-listing.r4`, and this pins the grammar it must enforce. +/// by `cut-desk-feed-listing.r6`, and this pins the grammar it must enforce. #[test] fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { for request in [forks(Some(ListStatus::Open), None, 50), read()] { -- 2.47.3