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:
parent
da90d7e74e
commit
d08b8880ff
9 changed files with 174 additions and 13 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
82a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464ad646563696465725f636f756e7402
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-decider.synthetic.payload.msgpack
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
‚¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4ddecider_count
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/card-viewers-non-decider.synthetic.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
82a8646563696465727390ad646563696465725f636f756e7402
|
||||
|
|
@ -0,0 +1 @@
|
|||
‚¨deciders<EFBFBD>decider_count
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7402a7616e737765727392de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/record-non-decider.proposed.payload.msgpack
vendored
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue