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 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-11 17:38:52 +00:00
commit d08b8880ff
9 changed files with 174 additions and 13 deletions

View file

@ -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<String>,
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<String>,
retracts: Option<String>,
code: Option<huginn_mind::desk_wire::ForkAnswerCode>,
options_hash: String,
choice: Option<String>,
note: Option<String>,
answered_at: String,
received_at: String,
claim_in_future: bool,
answerer: Option<String>,
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<String>,
answerer_count: u64,
answers: Vec<WithheldAnswer>,
}
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<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()),
]
}
/// The named MessagePack payload a CultNet operation message carries (base64 in the envelope).
fn payload(message: &CultNetMessage) -> Vec<u8> {
use base64::Engine;
@ -175,6 +277,10 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
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");
}

View file

@ -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.

View file

@ -0,0 +1 @@
82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402

View file

@ -0,0 +1 @@
‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d­decider_count

View file

@ -0,0 +1 @@
82a8646563696465727390ad646563696465725f636f756e7402

View file

@ -0,0 +1 @@
‚¨deciders<EFBFBD>­decider_count

View file

@ -0,0 +1 @@
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032

View file

@ -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)