Desk feed door: reader-count shapes as ruled (decider_count, nullable answerer, answerer_count)

Ruling desk-reader-counts-shape (Libby's CultLib reader-counts cut):
decider_count 1..64 right after deciders, deciders empty when withheld
or exactly decider_count ids; answerer nullable on all four variants and
null only in a non-decider's view; answerer_count over all stored
answers, refusals included; card 18 keys with raised_at, record 6.
Mirror fragments for both reader kinds and tests pinning those rules;
no CultLib bytes pinned until Libby posts her sha.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-11 17:53:32 +00:00
commit 30a05309c2
9 changed files with 124 additions and 56 deletions

View file

@ -28,6 +28,8 @@ const RUNTIME: &str = "huginn-eureka";
/// other decider). Synthetic values. /// other decider). Synthetic values.
const READER: &str = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f"; const READER: &str = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f";
const OTHER_DECIDER: &str = "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d"; const OTHER_DECIDER: &str = "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d";
/// A member who answered but is not a decider: their answer is stored `Refused(not_a_decider)`.
const OUTSIDER: &str = "c3a1f2e4-7b6d-4e8f-9a0b-1c2d3e4f5a6b";
/// A question Eureka's mind holds (asked 2026-11-11). The answers, notes and hash in `record()` are /// 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. /// synthetic: the hash is CultLib's sample digest, not this question's.
const FORK: &str = "eureka-substrate:question:desk-listing-authority"; const FORK: &str = "eureka-substrate:question:desk-listing-authority";
@ -102,33 +104,40 @@ fn record() -> ForkRecord {
answers: vec![ answers: vec![
answer("n1", "Counted", None, READER, "The asker lists her own forks.", "2026-11-11T17:00: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"), answer("n2", "Revision", Some("n1"), OTHER_DECIDER, "Agreed.", "2026-11-11T17:05:00Z"),
ForkDeskAnswer { code: Some(huginn_mind::desk_wire::ForkAnswerCode::NotADecider), ..answer("n3", "Refused", None, OUTSIDER, "Me too.", "2026-11-11T17:06:00Z") },
], ],
} }
} }
// ---- PROPOSED reader views (ruling: the door withholds account ids from readers who are not // ---- Reader views, as MIRROR TYPES (ruling desk-read-visibility-withheld-ids; shape ruling
// deciders, and returns counts). CultLib's card and desk_answer cannot carry this yet (card // desk-reader-counts-shape, Libby's CultLib reader-counts cut). These types exist only here: Huginn
// `deciders` has minItems 1 and no count key; `answerer` is a required non-null id), so these are // does not project reader views yet (cut-desk-feed-listing), and CultLib's re-pinned vectors are not
// fragments in the shape proposed to Libby (follow-up `cultlib-fork-card-reader-counts`), built from // posted yet. They pin the rules, not CultLib's bytes; when Libby posts the sha, her vectors are
// mirror types that exist only here. They are not Huginn's output until that contract lands and // decoded against Huginn's real types instead. ----
// `cut-desk-feed-listing` builds it. ----
/// The two card keys the proposal changes, as a fragment: a decider gets the ids, anyone else `[]` /// The card's `deciders` and `decider_count` (right after `deciders`), as a fragment. Not a card.
/// and the count. Not a card.
#[derive(Serialize)] #[derive(Serialize)]
struct CardViewers { struct CardViewers {
deciders: Vec<String>, deciders: Vec<String>,
decider_count: u64, decider_count: u64,
} }
/// What the call's deciders look like to one reader: the ids for a decider, `[]` (withheld) for
/// anyone else; the count either way.
fn card_viewers(decider: bool) -> CardViewers { fn card_viewers(decider: bool) -> CardViewers {
let ids = vec![READER.to_string(), OTHER_DECIDER.to_string()]; let ids = vec![READER.to_string(), OTHER_DECIDER.to_string()];
CardViewers { decider_count: ids.len() as u64, deciders: if decider { ids } else { Vec::new() } } 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. /// The card rule: `decider_count` is 1..64, and `deciders` is either empty (withheld) or holds
/// exactly `decider_count` ids.
fn card_viewers_hold(view: &CardViewers) -> bool {
(1..=64).contains(&view.decider_count) && (view.deciders.is_empty() || view.deciders.len() as u64 == view.decider_count)
}
/// `gamecult.desk_answer.v0` with `answerer` nullable (all four variants).
#[derive(Serialize)] #[derive(Serialize)]
struct WithheldAnswer { struct ViewAnswer {
#[serde(rename = "schemaVersion")] #[serde(rename = "schemaVersion")]
schema_version: AnswerSchema, schema_version: AnswerSchema,
answer_id: String, answer_id: String,
@ -148,25 +157,27 @@ struct WithheldAnswer {
request_id: String, request_id: String,
} }
/// `gamecult.fork.record.v0` as proposed for a reader who is not a decider: every `answerer` nil and /// `gamecult.fork.record.v0` with `answerer_count`: six keys (header 0x86). The count's position
/// `answerer_count`, the number of distinct answerers, in their place. /// among the six is CultLib's; this mirror puts it before `answers` until the vectors say.
#[derive(Serialize)] #[derive(Serialize)]
struct WithheldRecord { struct ViewRecord {
#[serde(rename = "schemaVersion")] #[serde(rename = "schemaVersion")]
schema_version: RecordSchema, schema_version: RecordSchema,
fork_id: String, fork_id: String,
options_hash: String, options_hash: String,
counted_answer_id: Option<String>, counted_answer_id: Option<String>,
answerer_count: u64, answerer_count: u64,
answers: Vec<WithheldAnswer>, answers: Vec<ViewAnswer>,
} }
fn withheld(record: ForkRecord) -> WithheldRecord { /// One record as one reader sees it. `answerer_count` is the distinct answerers over ALL stored
/// answers, refusals included, for both readers; only a non-decider's view nils `answerer`.
fn view(record: ForkRecord, decider: bool) -> ViewRecord {
let mut answerers: Vec<&str> = record.answers.iter().map(|answer| answer.answerer.as_str()).collect(); let mut answerers: Vec<&str> = record.answers.iter().map(|answer| answer.answerer.as_str()).collect();
answerers.sort_unstable(); answerers.sort_unstable();
answerers.dedup(); answerers.dedup();
let answerer_count = answerers.len() as u64; let answerer_count = answerers.len() as u64;
WithheldRecord { ViewRecord {
schema_version: record.schema_version, schema_version: record.schema_version,
fork_id: record.fork_id, fork_id: record.fork_id,
options_hash: record.options_hash, options_hash: record.options_hash,
@ -175,7 +186,7 @@ fn withheld(record: ForkRecord) -> WithheldRecord {
answers: record answers: record
.answers .answers
.into_iter() .into_iter()
.map(|answer| WithheldAnswer { .map(|answer| ViewAnswer {
schema_version: answer.schema_version, schema_version: answer.schema_version,
answer_id: answer.answer_id, answer_id: answer.answer_id,
fork_id: answer.fork_id, fork_id: answer.fork_id,
@ -189,7 +200,7 @@ fn withheld(record: ForkRecord) -> WithheldRecord {
answered_at: answer.answered_at, answered_at: answer.answered_at,
received_at: answer.received_at, received_at: answer.received_at,
claim_in_future: answer.claim_in_future, claim_in_future: answer.claim_in_future,
answerer: None, answerer: decider.then_some(answer.answerer),
authority: answer.authority, authority: answer.authority,
request_id: answer.request_id, request_id: answer.request_id,
}) })
@ -197,12 +208,18 @@ fn withheld(record: ForkRecord) -> WithheldRecord {
} }
} }
/// The proposed fragments by reader kind: (file stem, bytes). /// The record rule: `answerer` is null exactly in a non-decider's view.
fn record_view_holds(view: &ViewRecord, decider: bool) -> bool {
view.answers.iter().all(|answer| answer.answerer.is_some() == decider)
}
/// The mirror fragments by reader kind: (file stem, bytes).
fn reader_views() -> Vec<(&'static str, Vec<u8>)> { fn reader_views() -> Vec<(&'static str, Vec<u8>)> {
vec![ vec![
("card-viewers-decider.synthetic", rmp_serde::to_vec_named(&card_viewers(true)).unwrap()), ("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()), ("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()), ("record-decider.mirror", rmp_serde::to_vec_named(&view(record(), true)).unwrap()),
("record-non-decider.mirror", rmp_serde::to_vec_named(&view(record(), false)).unwrap()),
] ]
} }
@ -349,26 +366,56 @@ fn holds(bytes: &[u8], text: &str) -> bool {
bytes.windows(text.len()).any(|window| window == text.as_bytes()) bytes.windows(text.len()).any(|window| window == text.as_bytes())
} }
/// This test pins MIRROR TYPES ONLY (`CardViewers`, `WithheldRecord`), the shapes proposed to CultLib. /// MIRROR TYPES ONLY: these tests pin the reader-view rules on the types above, not Huginn's
/// It does not exercise Huginn's projection, which does not withhold ids yet. The tests on real list and /// projection (which does not withhold ids yet) and not CultLib's bytes (pending Libby's sha). The
/// read responses land with `cut-desk-feed-listing` (r6). /// tests on real list and read responses land with `cut-desk-feed-listing`.
/// ///
/// A reader who is not a decider gets counts and no account id at all: neither UUID appears /// A non-decider's fragments hold no account id anywhere in their bytes, only counts; a decider's
/// anywhere in its fragments' bytes. A decider gets every id. (Proposed shapes; the build cut pins /// hold every id and the same counts.
/// the same on Huginn's real list and read responses.)
#[test] #[test]
fn a_non_decider_gets_counts_and_no_ids_a_decider_gets_ids() { fn a_non_decider_gets_counts_and_no_ids_a_decider_gets_ids() {
let views = reader_views(); let views = reader_views();
let bytes = |name: &str| views.iter().find(|(stem, _)| *stem == name).unwrap().1.clone(); 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"] { for name in ["card-viewers-non-decider.synthetic", "record-non-decider.mirror"] {
let view = bytes(name); let held = bytes(name);
assert!(!holds(&view, READER) && !holds(&view, OTHER_DECIDER), "{name} carries an account id"); assert!([READER, OTHER_DECIDER, OUTSIDER].iter().all(|id| !holds(&held, id)), "{name} carries an account id");
} }
let non_decider = card_viewers(false); let held = bytes("card-viewers-decider.synthetic");
assert_eq!((non_decider.deciders.len(), non_decider.decider_count), (0, 2)); assert!(holds(&held, READER) && holds(&held, OTHER_DECIDER));
assert_eq!(withheld(record()).answerer_count, 2); let held = bytes("record-decider.mirror");
let decider = bytes("card-viewers-decider.synthetic"); assert!([READER, OTHER_DECIDER, OUTSIDER].iter().all(|id| holds(&held, id)), "a decider's record keeps every answerer id");
assert!(holds(&decider, READER) && holds(&decider, OTHER_DECIDER)); assert_eq!(card_viewers(false).decider_count, card_viewers(true).decider_count);
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");
/// `answerer_count` counts distinct answerers over every stored answer, the refused one included,
/// and is the same for both readers; a record view has six keys (header 0x86).
#[test]
fn answerer_count_covers_refusals_and_the_record_has_six_keys() {
assert_eq!(record().answers.iter().filter(|answer| answer.outcome == "Refused").count(), 1);
assert_eq!((view(record(), false).answerer_count, view(record(), true).answerer_count), (3, 3));
for name in ["record-decider.mirror", "record-non-decider.mirror"] {
let held = reader_views().into_iter().find(|(stem, _)| *stem == name).unwrap().1;
assert_eq!(held[0], 0x86, "{name}");
}
}
/// `decider_count` is 1..64; `deciders` is empty (withheld) or holds exactly `decider_count` ids;
/// `answerer` is null exactly in a non-decider's view.
#[test]
fn a_non_empty_decider_list_matches_its_count_and_answerer_is_null_only_for_non_deciders() {
assert!(card_viewers_hold(&card_viewers(true)) && card_viewers_hold(&card_viewers(false)));
let ids = |n: usize| (0..n).map(|at| format!("00000000-0000-4000-8000-{at:012x}")).collect::<Vec<_>>();
for (deciders, decider_count, holds) in [
(ids(1), 2, false),
(ids(3), 2, false),
(Vec::new(), 0, false),
(Vec::new(), 65, false),
(ids(64), 64, true),
(Vec::new(), 64, true),
(ids(1), 1, true),
] {
assert_eq!(card_viewers_hold(&CardViewers { deciders, decider_count }), holds);
}
assert!(record_view_holds(&view(record(), true), true) && record_view_holds(&view(record(), false), false));
assert!(!record_view_holds(&view(record(), true), false) && !record_view_holds(&view(record(), false), true));
} }

View file

@ -35,10 +35,13 @@ lands (see `docs/desk-feed-door.md`).
| `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) | | `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) | | `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) | | `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) |
| `record-whole` | fragment | `gamecult.fork.record.v0` with a counted answer and a revision, answerer ids and notes whole: what the door sends today, and a decider's view | | `record-whole` | fragment | `gamecult.fork.record.v0` with a counted answer, a revision and a refused answer, answerer ids and notes whole: what the door sends today (5 keys, no `answerer_count`) |
| `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 | | `record-decider.mirror` | fragment | MIRROR (ruling `desk-reader-counts-shape`): a record with three answers by three accounts (one refused), as a decider sees it: every `answerer` id, `answerer_count` 3, header `86`. Not Huginn output yet |
| `record-non-decider.mirror` | fragment | MIRROR: the same record for a non-decider, with every `answerer` nil and `answerer_count` 3. Not Huginn output yet |
| `card-viewers-decider.synthetic` | fragment | SYNTHETIC: only `{deciders: [two ids], decider_count: 2}`. Not a card | | `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 | | `card-viewers-non-decider.synthetic` | fragment | SYNTHETIC: only `{deciders: [], decider_count: 2}` (withheld). Not a card |
The mirror and synthetic fragments wait on Libby's CultLib vectors (sha pending). Once she posts it, Huginn's real types are decoded against those vectors instead.
No case carries a card (`gamecult.fork.card.v0`). The card's bytes wait on CultLib's re-pinned 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. vectors for `raised_at`, so no entry-bearing list page or read is pinned yet.

View file

@ -0,0 +1 @@
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727393de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033

View file

@ -1 +1 @@
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727393de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033

View file

@ -1 +1 @@
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032 85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727393de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033

View file

@ -118,20 +118,37 @@ 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 Bifrost, withholds account ids from a non-decider and returns a count instead. Bifrost cannot do
it, because the card is opaque to it. A decider sees the ids. The superseding ruling is in force: it, because the card is opaque to it. A decider sees the ids. The superseding ruling is in force:
`eureka-substrate:ruling:desk-read-visibility-withheld-ids`. `eureka-substrate:ruling:desk-read-visibility-withheld-ids`.
- **CultLib cannot carry this yet.** The card has 16 required keys in closed map-mode (17 once `raised_at` lands), and - **The shape** (ruling `eureka-substrate:ruling:desk-reader-counts-shape`, from Libby's CultLib
`deciders` must hold at least one item. `answerer` is a required, non-null id. The proposal to reader-counts cut). CultLib main does not have it yet: on main the card has 16 required keys and
Libby (follow-up `cultlib-fork-card-reader-counts`): `deciders` needs at least one item, and `answerer` is a required, non-null id.
- the card gains a required `decider_count` (an integer). A non-decider gets `deciders: []`; a - **Card:** `decider_count` is an integer from 1 to 64, placed right after `deciders`. An empty
decider gets the ids. The count is the same for both. `deciders` means the ids are withheld from this reader; a non-empty list holds exactly
- `answerer` becomes nullable: nil for a non-decider, with the key kept, as `note` is stripped. `decider_count` ids. With `raised_at` the card has 18 keys (MessagePack header `DE 00 12`).
- the record gains a required `answerer_count`, the number of distinct answerers. - **Record:** `answerer_count` is the number of distinct answerers over ALL the fork's stored
- **Proposed fixtures**, built from mirror types that exist only in the test: answers, refusals included. It is the same for both readers. The record has 6 keys (header `86`).
- `card-viewers-decider.synthetic` and `card-viewers-non-decider.synthetic` hold only the two card - **Answers:** `answerer` is nullable on all four variants (`Counted`, `Revision`, `Retraction`,
keys the proposal changes. They are not cards. `Refused`). It is null only in a non-decider's view, with the key kept. Stored answers always
- `record-non-decider.proposed` is a whole record as the proposal shapes it. carry it.
- `options_hash` does not change (still five members).
- **Mirror fixtures.** These come from types that exist only in the test. They are neither CultLib's
bytes nor Huginn's output:
- `card-viewers-decider.synthetic` and `card-viewers-non-decider.synthetic` hold only `deciders`
and `decider_count`. They are not cards.
- `record-decider.mirror` and `record-non-decider.mirror` are a record with three answers by three
accounts (one of them refused) as each reader sees it.
The test `a_non_decider_gets_counts_and_no_ids_a_decider_gets_ids` checks that neither account id The tests check:
appears anywhere in a non-decider's bytes. `record-whole` is the decider's view. - a non-decider's bytes hold no account id anywhere, and a decider's hold every id;
- `answerer_count` is 3, counting the refusal, for both readers, and each record view starts with
`86`;
- a non-empty `deciders` must match `decider_count`, and the count stays within 1 to 64;
- `answerer` is null exactly in a non-decider's view.
Where `answerer_count` sits among the record's six keys is CultLib's to fix. The mirror puts it
before `answers`.
- **Pending:** Libby's re-pinned vectors. When she posts the CultLib sha, Huginn's real types are
decoded against those vectors and these mirrors are retired (`cut-desk-feed-listing`). No byte
beyond Huginn's own envelope is pinned until then.
Response (`huginn.desk_response.v1`): Response (`huginn.desk_response.v1`):
@ -182,7 +199,7 @@ An empty list is never served in place of a failure. `open_count: 0` means zero
## Card fields (pending CultLib's re-pinned card vectors) ## 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 `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. RFC3339 string or nil, which takes the card from 16 to 17 keys in closed map-mode (18 with `decider_count`). Nil means unknown.
A string is the question's first-admission time, which never moves. It is outside `options_hash`, 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 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 question's admission: the `committed_at` of the receipt that first admitted the question, or for an