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.
const READER: &str = "5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f";
const OTHER_DECIDER: &str = "0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d";
/// A member who answered but is not a decider: their answer is stored `Refused(not_a_decider)`.
const OUTSIDER: &str = "c3a1f2e4-7b6d-4e8f-9a0b-1c2d3e4f5a6b";
/// A question Eureka's mind holds (asked 2026-11-11). The answers, notes and hash in `record()` are
/// synthetic: the hash is CultLib's sample digest, not this question's.
const FORK: &str = "eureka-substrate:question:desk-listing-authority";
@ -102,33 +104,40 @@ fn record() -> ForkRecord {
answers: vec![
answer("n1", "Counted", None, READER, "The asker lists her own forks.", "2026-11-11T17:00:00Z"),
answer("n2", "Revision", Some("n1"), OTHER_DECIDER, "Agreed.", "2026-11-11T17:05:00Z"),
ForkDeskAnswer { code: Some(huginn_mind::desk_wire::ForkAnswerCode::NotADecider), ..answer("n3", "Refused", None, OUTSIDER, "Me too.", "2026-11-11T17:06:00Z") },
],
}
}
// ---- 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. ----
// ---- Reader views, as MIRROR TYPES (ruling desk-read-visibility-withheld-ids; shape ruling
// desk-reader-counts-shape, Libby's CultLib reader-counts cut). These types exist only here: Huginn
// does not project reader views yet (cut-desk-feed-listing), and CultLib's re-pinned vectors are not
// posted yet. They pin the rules, not CultLib's bytes; when Libby posts the sha, her vectors are
// decoded against Huginn's real types instead. ----
/// The two card keys the proposal changes, as a fragment: a decider gets the ids, anyone else `[]`
/// and the count. Not a card.
/// The card's `deciders` and `decider_count` (right after `deciders`), as a fragment. Not a card.
#[derive(Serialize)]
struct CardViewers {
deciders: Vec<String>,
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 {
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.
/// 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)]
struct WithheldAnswer {
struct ViewAnswer {
#[serde(rename = "schemaVersion")]
schema_version: AnswerSchema,
answer_id: String,
@ -148,25 +157,27 @@ struct WithheldAnswer {
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.
/// `gamecult.fork.record.v0` with `answerer_count`: six keys (header 0x86). The count's position
/// among the six is CultLib's; this mirror puts it before `answers` until the vectors say.
#[derive(Serialize)]
struct WithheldRecord {
struct ViewRecord {
#[serde(rename = "schemaVersion")]
schema_version: RecordSchema,
fork_id: String,
options_hash: String,
counted_answer_id: Option<String>,
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();
answerers.sort_unstable();
answerers.dedup();
let answerer_count = answerers.len() as u64;
WithheldRecord {
ViewRecord {
schema_version: record.schema_version,
fork_id: record.fork_id,
options_hash: record.options_hash,
@ -175,7 +186,7 @@ fn withheld(record: ForkRecord) -> WithheldRecord {
answers: record
.answers
.into_iter()
.map(|answer| WithheldAnswer {
.map(|answer| ViewAnswer {
schema_version: answer.schema_version,
answer_id: answer.answer_id,
fork_id: answer.fork_id,
@ -189,7 +200,7 @@ fn withheld(record: ForkRecord) -> WithheldRecord {
answered_at: answer.answered_at,
received_at: answer.received_at,
claim_in_future: answer.claim_in_future,
answerer: None,
answerer: decider.then_some(answer.answerer),
authority: answer.authority,
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>)> {
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()),
("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())
}
/// 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).
/// MIRROR TYPES ONLY: these tests pin the reader-view rules on the types above, not Huginn's
/// projection (which does not withhold ids yet) and not CultLib's bytes (pending Libby's sha). The
/// 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
/// 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.)
/// A non-decider's fragments hold no account id anywhere in their bytes, only counts; a decider's
/// hold every id and the same counts.
#[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");
for name in ["card-viewers-non-decider.synthetic", "record-non-decider.mirror"] {
let held = bytes(name);
assert!([READER, OTHER_DECIDER, OUTSIDER].iter().all(|id| !holds(&held, id)), "{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");
let held = bytes("card-viewers-decider.synthetic");
assert!(holds(&held, READER) && holds(&held, OTHER_DECIDER));
let held = bytes("record-decider.mirror");
assert!([READER, OTHER_DECIDER, OUTSIDER].iter().all(|id| holds(&held, id)), "a decider's record keeps every answerer id");
assert_eq!(card_viewers(false).decider_count, card_viewers(true).decider_count);
}
/// `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) |
| `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) |
| `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) |
| `record-whole` | fragment | `gamecult.fork.record.v0` with a counted answer and a revision, answerer ids and notes whole: what the door sends today, and a decider's view |
| `record-non-decider.proposed` | fragment | PROPOSED non-decider record: every `answerer` nil, `answerer_count` 2. Needs CultLib change `cultlib-fork-card-reader-counts`; not Huginn output yet |
| `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-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-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
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
it, because the card is opaque to it. A decider sees the ids. The superseding ruling is in force:
`eureka-substrate:ruling:desk-read-visibility-withheld-ids`.
- **CultLib cannot carry this yet.** The card has 16 required keys in closed map-mode (17 once `raised_at` lands), and
`deciders` must hold at least one item. `answerer` is a required, non-null id. The proposal to
Libby (follow-up `cultlib-fork-card-reader-counts`):
- the card gains a required `decider_count` (an integer). A non-decider gets `deciders: []`; a
decider gets the ids. The count is the same for both.
- `answerer` becomes nullable: nil for a non-decider, with the key kept, as `note` is stripped.
- the record gains a required `answerer_count`, the number of distinct answerers.
- **Proposed fixtures**, built from mirror types that exist only in the test:
- `card-viewers-decider.synthetic` and `card-viewers-non-decider.synthetic` hold only the two card
keys the proposal changes. They are not cards.
- `record-non-decider.proposed` is a whole record as the proposal shapes it.
- **The shape** (ruling `eureka-substrate:ruling:desk-reader-counts-shape`, from Libby's CultLib
reader-counts cut). CultLib main does not have it yet: on main the card has 16 required keys and
`deciders` needs at least one item, and `answerer` is a required, non-null id.
- **Card:** `decider_count` is an integer from 1 to 64, placed right after `deciders`. An empty
`deciders` means the ids are withheld from this reader; a non-empty list holds exactly
`decider_count` ids. With `raised_at` the card has 18 keys (MessagePack header `DE 00 12`).
- **Record:** `answerer_count` is the number of distinct answerers over ALL the fork's stored
answers, refusals included. It is the same for both readers. The record has 6 keys (header `86`).
- **Answers:** `answerer` is nullable on all four variants (`Counted`, `Revision`, `Retraction`,
`Refused`). It is null only in a non-decider's view, with the key kept. Stored answers always
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
appears anywhere in a non-decider's bytes. `record-whole` is the decider's view.
The tests check:
- 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`):
@ -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)
`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`,
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