Desk door: emit CultLib d3025d3c reader shapes (PR A of cut-desk-feed-raised-at.r3 + cut-desk-feed-listing.r8) (#62)

This commit is contained in:
Eureka 2026-11-11 21:24:36 +00:00 • committed by Eureka
commit dcae9c5c2c
44 changed files with 6093 additions and 1694 deletions

View file

@ -263,8 +263,8 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
/// One desk request, answered by the Mind method it names. The Mind is the instance's own
/// (`mind` is checked against it); `deciders` is this call's list, handed to the Mind and
/// dropped. A desk answer is not indexed (`index_text` gives it no text), so nothing here tells the index.
/// The reads answer with the whole record and nothing here gates them (ruling
/// `desk-read-visibility-whole-record`): they are handed `deciders` and not `subject`.
/// The reads are handed `subject` and `deciders`: the Mind builds a decider's view or a
/// non-decider's (ruling `desk-read-visibility-withheld-ids`).
pub fn handle_desk(&mut self, request: DeskRequest, now: DateTime<Utc>) -> DeskResponse {
if let Err(refusal) = self.mind.require_instance(request.mind()) {
return DeskResponse::Refused(refusal);
@ -273,8 +273,8 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
self.mind.desk_answer(&deciders, subject, authority, request, now)
}
DeskRequest::Forks { deciders, status, cursor, limit, .. } => self.mind.desk_forks(&deciders, status, cursor, limit),
DeskRequest::Fork { deciders, fork_id, .. } => self.mind.desk_fork(&deciders, &fork_id),
DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&subject, &deciders, status, cursor, limit),
DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&subject, &deciders, &fork_id),
}
}

View file

@ -10,16 +10,15 @@
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name (a read of one
//! fork past it is that fork's `fork_too_large`).
//!
//! The trust boundary of the reads (ruling `desk-read-visibility-whole-record`). The peer-uid check
//! The trust boundary of the reads (ruling `desk-read-visibility-withheld-ids`). The peer-uid check
//! IS the whole authentication: Huginn must be reachable only by Bifrost's peer-checked socket, so
//! the configured uid is Bifrost's and no other process's. `Forks` and `Fork` return the WHOLE record
//! (cards, outcomes, every decider's note) to whoever passes that check; nothing in Huginn strips a
//! note or gates a read on `subject` or `deciders` (`deciders` only fills the card and the
//! showability gate). Bifrost applies visibility at its own door (a stripped read serves `note: null`,
//! CultLib `fork-card.md`). A second reader of this socket is therefore a note leak, and a deployment
//! that gives the socket to any uid but Bifrost's has no reader check left. The reads carry no
//! `authority` (a payload that names one is refused whole), and the door has no other way to ask who
//! is reading.
//! the configured uid is Bifrost's and no other process's. `Forks` and `Fork` carry every decider's
//! note to whoever passes that check, and Bifrost strips notes at its own door (a stripped read
//! serves `note: null`, CultLib `fork-card.md`); a second reader of this socket is therefore a note
//! leak, and a deployment that gives the socket to any uid but Bifrost's has no reader check left.
//! Account ids are Huginn's to withhold: a reader whose `subject` is not in the call's `deciders`
//! gets `deciders` empty, `answerer` nil and the counts instead. The reads carry no `authority` (a
//! payload that names one is refused whole), and the door has no other way to ask who is reading.
//!
//! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is
//! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is

View file

@ -855,10 +855,10 @@ mod door_smoke {
}
}
/// Ruling `desk-read-visibility-whole-record`: Huginn returns the whole record, notes included, to
/// whoever the socket carries. The subject is not in the deciders; the decider's note is there.
/// Ruling `desk-read-visibility-withheld-ids`: a subject the call's deciders do not name gets counts
/// and no account id, over the socket too; notes stay whole (Bifrost strips them). A decider gets the ids.
#[test]
fn a_subject_not_in_deciders_reads_the_whole_record_with_the_notes() {
fn a_subject_not_in_deciders_gets_counts_and_no_account_id() {
let mut rig = rig(my_uid());
let hash = rig.hash.clone();
let note = "Keep it local: the author's call.";
@ -875,9 +875,15 @@ mod door_smoke {
for entry in [&list.forks[0], &read] {
assert_eq!(entry.record.answers.len(), 1);
assert_eq!(entry.record.answers[0].note.as_deref(), Some(note));
assert_eq!(entry.record.answers[0].answerer, "y");
assert_eq!(entry.card.deciders, ["x"], "the list is the call's, and only fills the card");
assert_eq!(entry.record.answers[0].answerer, None);
assert_eq!(entry.record.answerer_count, 1);
assert!(entry.card.deciders.is_empty());
assert_eq!(entry.card.decider_count, 1);
}
let seen = rig.serving(|path| reply_of(path, "m-4", &fork_read("x", &["x"], FORK)));
let (seen, _) = entry_of(seen);
assert_eq!(seen.card.deciders, ["x"]);
assert_eq!(seen.record.answers[0].answerer.as_deref(), Some("y"));
}
#[test]

View file

@ -277,7 +277,7 @@ fn the_binary_serves_both_doors_and_survives_restart() {
let catalog = format!("{:?}", exchange(&desk, &framed(&catalog_request())).expect("a catalog"));
assert!(catalog.contains("huginn.desk_request.v1") && catalog.contains("huginn.desk_response.v1"), "{catalog}");
assert!(!catalog.contains("huginn.mind_request.v1"), "{catalog}");
// The reads answer, store nothing, and carry the whole record to a subject the deciders do not name.
// The reads answer and store nothing; a decider reading its own list is told the ids.
let (list, listed_at) = match ask(&desk, "m-r1", &forks_request("x", &["x"])) {
DeskResponse::Forks { list, as_of } => (list, as_of),
other => panic!("{other:?}"),
@ -319,15 +319,16 @@ fn the_binary_serves_both_doors_and_survives_restart() {
assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1")));
let stored = daemon.documents();
assert_eq!(stored, seeded + 2);
// A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code.
// A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code, and is told no account id.
let DeskResponse::Fork { entry, .. } =
ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() })
else {
panic!("a read")
};
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_str(), answer.code)).collect();
assert_eq!(seen, [("n1", "x", None), ("n2", "x", Some(ForkAnswerCode::StaleFork))]);
assert_eq!((entry.record.counted_answer_id.as_deref(), entry.card.deciders.as_slice()), (Some("n1"), ["y".to_string()].as_slice()));
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_deref(), answer.code)).collect();
assert_eq!(seen, [("n1", None, None), ("n2", None, Some(ForkAnswerCode::StaleFork))]);
assert_eq!((entry.record.counted_answer_id.as_deref(), entry.record.answerer_count), (Some("n1"), 1));
assert_eq!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[][..], 1));
assert_eq!(daemon.documents(), stored);
// A stop takes the socket with it; the stored answers are the retry's memory.

View file

@ -936,7 +936,7 @@ mod tests {
assert_eq!(counted(&mind, FORK), Some(answer));
}
/// Libby's vectors (CultLib 6231ecad, `contracts/cultnet/governance/fork-card-vectors.json`, the `optionsHash` array):
/// Libby's vectors (CultLib d3025d3c, `contracts/cultnet/governance/fork-card-vectors.json`, the `optionsHash` array):
/// the seven of the binding file, A to G, and the eight she adds. Each is recomputed from its shown members.
fn vectors() -> Vec<serde_json::Value> {
let file: serde_json::Value = serde_json::from_str(include_str!("../vectors/fork-card-vectors.json")).unwrap();

View file

@ -1,5 +1,5 @@
//! The fork desk's answer wire, Huginn's half: Libby's answer-side documents
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at e57fbaee)
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at d3025d3c)
//! as Rust types, the one conversion between her `n<seq>` answer ids and the
//! record keys, the projection of a stored answer, the refusal mapping and the
//! showability predicate, and `Mind::desk_answer`, the one call that runs them in order.
@ -147,7 +147,10 @@ pub struct ForkDeskAnswer {
pub answered_at: String,
pub received_at: String,
pub claim_in_future: bool,
pub answerer: String,
/// The answering account, or nil in a non-decider's view (the key is kept). A stored answer always has one.
#[serde(deserialize_with = "required")]
#[schemars(required)]
pub answerer: Option<String>,
pub authority: DeskAuthority,
pub request_id: String,
}
@ -180,12 +183,12 @@ pub struct ForkRefusal {
/// `Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and
/// Huginn writes no document for it.
///
/// The trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`
/// carry the WHOLE record, every answer's note included, to whatever the peer-checked socket
/// carries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a
/// stripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable
/// only by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a
/// second reader of that socket is a note leak.
/// The trust boundary of the reads (ruling `desk-read-visibility-withheld-ids`): `Forks` and `Fork`
/// carry every answer's note, to whatever the peer-checked socket carries; Bifrost strips notes
/// at its door (a stripped read serves `note: null`, CultLib `fork-card.md`). Huginn itself
/// withholds the account ids from a reader who is not a decider (`deciders` empty, `answerer` nil,
/// counts in their place). Huginn must be reachable only by Bifrost's peer-checked socket: the
/// peer-uid check IS the whole authentication, and a second reader of that socket is a note leak.
// One value per call, built and encoded once; a box would only add a deref to every reader.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@ -275,12 +278,11 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
/// the set refuses the whole payload.
///
/// The reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and
/// `read_request` are closed and have none; one that names it is refused whole). Their `subject`
/// and `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability
/// gate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat
/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn
/// answers every read with the whole record and Bifrost applies visibility; see `DeskResponse`
/// for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
/// `read_request` are closed and have none; one that names it is refused whole). Their `deciders`
/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider
/// and is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat
/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost
/// strips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
/// reads spec.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@ -459,7 +461,7 @@ fn project(key: &str, stored: &PipelineDeskAnswer) -> Result<ForkDeskAnswer, Min
answered_at: stored.answered_at.0.clone(),
received_at: stored.received_at.0.clone(),
claim_in_future: stored.claim_in_future,
answerer: stored.answerer.0.clone(),
answerer: Some(stored.answerer.0.clone()),
authority: stored.authority,
request_id: stored.request_id.0.clone(),
})
@ -566,7 +568,7 @@ mod tests {
const LIST: [&str; 4] = ["x", "y", "z", "op"];
const AT: &str = "2026-11-10T12:00:00Z";
// ---- Libby's vectors: CultLib 6231ecad, `contracts/cultnet/governance/fork-card-vectors.json` ----
// ---- Libby's vectors: CultLib d3025d3c, `contracts/cultnet/governance/fork-card-vectors.json` ----
fn vectors() -> Value {
serde_json::from_str(include_str!("../vectors/fork-card-vectors.json")).unwrap()
@ -776,7 +778,8 @@ mod tests {
claim_in_future: message["claim_in_future"].as_bool().unwrap(),
request_id: RequestId(text("request_id").unwrap()),
body_sha256: Sha256Hex("0".repeat(64)),
answerer: Short(text("answerer").unwrap()),
// A withheld vector names no answerer: store a placeholder, the vector is the non-decider's view of it.
answerer: Short(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())),
authority,
outcome,
};
@ -787,10 +790,14 @@ mod tests {
fn stored_answers_project_to_the_desk_answer_vectors() {
let file = vectors();
let valid = rows(&file["deskAnswer"]["valid"]);
assert_eq!(valid.len(), 5);
assert_eq!(valid.len(), 8);
for (name, message, bytes) in &valid {
let (key, stored) = stored_from(message);
let projected = project(&key, &stored).unwrap();
let mut projected = project(&key, &stored).unwrap();
// A null answerer is a non-decider's view of the stored answer.
if message["answerer"].is_null() {
projected.answerer = None;
}
assert_eq!(&encode(&projected), bytes, "{name}");
}
// Each answer inside her results is the projection of an equivalent record too.
@ -1348,9 +1355,9 @@ mod tests {
let (other, committed) = ask(&mut mind, "y", request.clone());
let (other, _, _) = answered(other);
assert!(committed);
assert_eq!((other.answer_id.as_str(), other.answerer.as_str(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str()));
assert_eq!((other.answer_id.as_str(), other.answerer.as_deref().unwrap(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str()));
let (original, _, _) = answered(ask(&mut mind, "x", request.clone()).0);
assert_eq!((original.answer_id.as_str(), original.answerer.as_str()), ("n1", "x"));
assert_eq!((original.answer_id.as_str(), original.answerer.as_deref().unwrap()), ("n1", "x"));
// V17 and V18: an id that is no lowercase D-format UUID is body_invalid naming request_id, and binds nothing.
let before = head(&mind);
for bad in ["retry-1", "0000000A-0000-4000-8000-00000000000B", "{00000000-0000-4000-8000-000000000001}", "00000000-0000-4000-8000-0000000000010", "", " 00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000001 ", "00000000-0000-4000-8000-000000000001\n", "00000000-0000-4000-8000-000000000001x"] {
@ -1565,7 +1572,7 @@ mod tests {
assert_eq!(answered(off).0.code, Some(ForkAnswerCode::NotADecider));
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now());
let (answer, _, _) = answered(on);
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_str()), ("Counted", DeskAuthority::Member, "m"));
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, "m"));
}
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----

View file

@ -1,17 +1,19 @@
//! The fork desk's read side: the card, the record, the entry and the list as Libby's closed
//! map-mode documents (CultLib `fork-card.schema.json` at e57fbaee), their projection from the
//! map-mode documents (CultLib `fork-card.schema.json` at d3025d3c), their projection from the
//! stored question and desk answers, and the two Mind calls the door dispatches to,
//! `Mind::desk_forks` and `Mind::desk_fork`. These docs, the doc of `DeskRequest` and the doc of
//! `DeskResponse` are the reads spec.
//!
//! Everything in one response is read at ONE snapshot, `image.head`, returned as `as_of`; the
//! list's `stamp` is `s<as_of>`. The calls take the call's `deciders` and nothing about who is
//! asking: `deciders` fills each card's `deciders` and is the showability gate's input, and no
//! call is handed the subject, so no rule here can read who the reader is. Huginn returns the
//! whole record, every note included; Bifrost applies visibility (ruling
//! `desk-read-visibility-whole-record`). Nothing is stored.
//! list's `stamp` is `s<as_of>`. The calls take the call's `subject` and `deciders`: the reader is
//! a decider when the subject is in the deciders (compared exactly, computed once per call, never
//! stored). A decider is told the decider ids and every answerer; any other reader gets `deciders`
//! empty, `answerer` nil, and the counts (`decider_count`, `answerer_count`) in their place (ruling
//! `desk-read-visibility-withheld-ids`, shape `desk-reader-counts-shape`). Notes stay whole on the
//! wire; Bifrost strips them. Nothing is stored.
use super::*;
use chrono::SecondsFormat;
use crate::desk::answers_of;
use crate::desk::options_hash;
@ -69,7 +71,7 @@ pub enum CardStatus {
/// `gamecult.fork.card.v0`: closed, her field order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[serde(try_from = "CardFields")]
pub struct ForkCard {
#[serde(rename = "schemaVersion")]
pub schema_version: CardSchema,
@ -88,7 +90,15 @@ pub struct ForkCard {
#[serde(deserialize_with = "required")]
#[schemars(required)]
pub raised_in: Option<ForkRef>,
/// RFC3339 (UTC, seconds, `Z`) of the question's first admission, or nil when unknown. Outside `options_hash`.
#[serde(deserialize_with = "required")]
#[schemars(required)]
pub raised_at: Option<String>,
/// The call's decider ids for a decider; empty for any other reader (the ids are withheld, never "no deciders").
pub deciders: Vec<String>,
/// How many deciders the call names, 1 to 64, whoever the reader is. A non-empty `deciders` holds exactly this many.
#[schemars(range(min = 1, max = 64))]
pub decider_count: u64,
pub status: CardStatus,
#[serde(deserialize_with = "required")]
#[schemars(required)]
@ -100,6 +110,81 @@ pub struct ForkCard {
pub options_hash: String,
}
/// The card as the wire carries it, before the count is checked against the ids: `ForkCard` decodes
/// through this and refuses a count outside 1..64 or a non-empty `deciders` of another length.
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CardFields {
#[serde(rename = "schemaVersion")]
schema_version: CardSchema,
fork_id: String,
title: String,
context: String,
options: Vec<ForkOption>,
recommendation: String,
depends: Vec<String>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
leans: Option<String>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
flag: Option<String>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
raised_in: Option<ForkRef>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
/// RFC3339 (UTC, seconds, `Z`) of the question's first admission, or nil when unknown. Outside `options_hash`.
raised_at: Option<String>,
/// The call's decider ids for a decider; empty for any other reader (the ids are withheld, never "no deciders").
deciders: Vec<String>,
/// How many deciders the call names, 1 to 64, whoever the reader is. A non-empty `deciders` holds exactly this many.
#[schemars(range(min = 1, max = 64))]
decider_count: u64,
status: CardStatus,
#[serde(deserialize_with = "required")]
#[schemars(required)]
ruling: Option<String>,
ruled_from: Vec<String>,
/// The question's rank: unique, 0-based, in (admission ordinal, key) order among all the Mind's
/// questions (see `Mind::desk_forks`; it deviates from "the admission ordinal" only in being unique).
order: u64,
options_hash: String,
}
impl TryFrom<CardFields> for ForkCard {
type Error = String;
fn try_from(card: CardFields) -> Result<Self, String> {
if !(1..=DESK_DECIDERS_MAX as u64).contains(&card.decider_count) {
return Err(format!("decider_count is 1 to {DESK_DECIDERS_MAX}"));
}
if !card.deciders.is_empty() && card.deciders.len() as u64 != card.decider_count {
return Err("deciders, when not withheld, holds exactly decider_count ids".into());
}
Ok(Self {
schema_version: card.schema_version,
fork_id: card.fork_id,
title: card.title,
context: card.context,
options: card.options,
recommendation: card.recommendation,
depends: card.depends,
leans: card.leans,
flag: card.flag,
raised_in: card.raised_in,
raised_at: card.raised_at,
deciders: card.deciders,
decider_count: card.decider_count,
status: card.status,
ruling: card.ruling,
ruled_from: card.ruled_from,
order: card.order,
options_hash: card.options_hash,
})
}
}
/// `gamecult.fork.record.v0`: the answers in admission order and the derived counted answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@ -111,6 +196,8 @@ pub struct ForkRecord {
#[serde(deserialize_with = "required")]
#[schemars(required)]
pub counted_answer_id: Option<String>,
/// Distinct answerers over ALL the fork's stored answers, refusals included; the same for every reader.
pub answerer_count: u64,
pub answers: Vec<ForkDeskAnswer>,
}
@ -227,8 +314,9 @@ impl JsonSchema for ForkLimit {
}
}
/// The card of a question held at the snapshot `docs` reads, shown to the call's `deciders`.
fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short]) -> ForkCard {
/// The card of a question held at the snapshot `docs` reads, for the call's `deciders`. A reader who
/// is a decider gets the ids; any other reader gets `deciders` empty and the count alone.
fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short], decider: bool) -> ForkCard {
let open = docs.in_force(PipelineKind::Question, key);
// The ruling that closed the fork: the in-force one that answers it (the key order picks if a second ever does).
let ruling = (!open)
@ -253,7 +341,9 @@ fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion,
leans: None,
flag: None,
raised_in: question.raised_in.as_ref().map(|raised| ForkRef { kind: format!("{:?}", raised.kind), id: raised.id.0.clone() }),
deciders: deciders.iter().map(|decider| decider.0.clone()).collect(),
raised_at: docs.first_admitted_at(PipelineKind::Question, key).map(|time| time.to_rfc3339_opts(SecondsFormat::Secs, true)),
deciders: if decider { deciders.iter().map(|id| id.0.clone()).collect() } else { Vec::new() },
decider_count: deciders.len() as u64,
status: if open { CardStatus::Open } else { CardStatus::Closed },
ruling,
ruled_from: Vec::new(),
@ -276,12 +366,18 @@ fn ranked<'d>(docs: &'d Docs<'_>) -> Result<Vec<(u64, &'d str, &'d PipelineQuest
}
/// The record of a fork: its answers in admission order and the answer that counts at this snapshot.
fn record_of(docs: &Docs<'_>, key: &str, options_hash: &str) -> Result<ForkRecord, MindRefusal> {
/// `answerer_count` is over every stored answer, refusals included, for every reader; only a decider
/// is told who the answerers are.
fn record_of(docs: &Docs<'_>, key: &str, options_hash: &str, decider: bool) -> Result<ForkRecord, MindRefusal> {
let mut stored: Vec<_> = answers_of(docs, key).collect();
stored.sort_by_key(|(_, answer)| answer.sequence);
let answers = stored.into_iter().map(|(answer_key, answer)| project(answer_key, answer)).collect::<Result<Vec<_>, _>>()?;
let answerer_count = stored.iter().map(|(_, answer)| answer.answerer.0.as_str()).collect::<std::collections::BTreeSet<_>>().len() as u64;
let answers = stored
.into_iter()
.map(|(answer_key, answer)| project(answer_key, answer).map(|shown| if decider { shown } else { ForkDeskAnswer { answerer: None, ..shown } }))
.collect::<Result<Vec<_>, _>>()?;
let counted_answer_id = counted_answer(docs, key).map(|counted| answer_seq(key, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?;
Ok(ForkRecord { schema_version: RecordSchema::V0, fork_id: key.into(), options_hash: options_hash.into(), counted_answer_id, answers })
Ok(ForkRecord { schema_version: RecordSchema::V0, fork_id: key.into(), options_hash: options_hash.into(), counted_answer_id, answerer_count, answers })
}
/// What `response` encodes to as a CultNet message with one-byte ids; `DESK_ENVELOPE_MARGIN` covers
@ -300,9 +396,9 @@ fn held(response: &mut DeskResponse) -> &mut ForkList {
}
}
fn entry_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short]) -> Result<ForkEntry, MindRefusal> {
let card = card_of(docs, key, order, question, deciders);
let record = record_of(docs, key, &card.options_hash)?;
fn entry_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, deciders: &[Short], decider: bool) -> Result<ForkEntry, MindRefusal> {
let card = card_of(docs, key, order, question, deciders, decider);
let record = record_of(docs, key, &card.options_hash, decider)?;
Ok(ForkEntry { schema_version: EntrySchema::V0, card, record })
}
@ -331,22 +427,23 @@ impl<S: MindStore> Mind<S> {
/// The fork itself is still its whole entry on `desk_fork`; if that entry is past the reply cap the door
/// answers the read with `fork_too_large` naming the fork (no order, no cursor), not `ResponseTooLarge`. Nothing is dropped silently.
///
/// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and
/// feed the showability gate, and gate nothing else. The record is whole, notes included
/// (ruling `desk-read-visibility-whole-record`: Bifrost applies visibility, Huginn strips nothing).
pub fn desk_forks(&self, deciders: &[Short], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: ForkLimit) -> DeskResponse {
self.forks_page(deciders, status, cursor, limit).unwrap_or_else(door_refusal)
/// The call's `deciders` feed the showability gate; the call's `subject` decides which view of each entry
/// is built (a decider's, with ids, or a non-decider's, with counts). It gates no fork. Notes stay whole
/// (Bifrost strips them).
pub fn desk_forks(&self, subject: &Short, deciders: &[Short], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: ForkLimit) -> DeskResponse {
self.forks_page(subject, deciders, status, cursor, limit).unwrap_or_else(door_refusal)
}
/// One fork, as the entry a page carries, read at one snapshot. A fork that is not held, is not a
/// question or is not showable is `fork_unknown`, the answer path's refusal. Whole record, as `desk_forks`.
pub fn desk_fork(&self, deciders: &[Short], fork_id: &str) -> DeskResponse {
self.fork_entry(deciders, fork_id).unwrap_or_else(door_refusal)
/// question or is not showable is `fork_unknown`, the answer path's refusal. The view is the reader's, as `desk_forks`.
pub fn desk_fork(&self, subject: &Short, deciders: &[Short], fork_id: &str) -> DeskResponse {
self.fork_entry(subject, deciders, fork_id).unwrap_or_else(door_refusal)
}
fn forks_page(&self, deciders: &[Short], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: ForkLimit) -> Result<DeskResponse, MindRefusal> {
fn forks_page(&self, subject: &Short, deciders: &[Short], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: ForkLimit) -> Result<DeskResponse, MindRefusal> {
let image = self.image()?;
let docs = Docs::at(image, image.head);
let decider = deciders.contains(subject);
let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, _, question)| showable(question, deciders)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect();
shown.reverse();
let open_count = shown.iter().filter(|fork| fork.3).count() as u64;
@ -378,7 +475,7 @@ impl<S: MindStore> Mind<S> {
if held(&mut response).forks.len() == limit.get() as usize {
break;
}
held(&mut response).forks.push(entry_of(&docs, key, *order, question, deciders)?);
held(&mut response).forks.push(entry_of(&docs, key, *order, question, deciders, decider)?);
let before = held(&mut response).next_cursor;
held(&mut response).next_cursor = if at + 1 < matching.len() { ForkCursor::new(*order) } else { None };
let bytes = page_bytes(&response)?;
@ -395,16 +492,17 @@ impl<S: MindStore> Mind<S> {
Ok(response)
}
fn fork_entry(&self, deciders: &[Short], fork_id: &str) -> Result<DeskResponse, MindRefusal> {
fn fork_entry(&self, subject: &Short, deciders: &[Short], fork_id: &str) -> Result<DeskResponse, MindRefusal> {
let image = self.image()?;
let docs = Docs::at(image, image.head);
let decider = deciders.contains(subject);
let Some((order, key, question)) = ranked(&docs)?.into_iter().find(|(_, key, _)| *key == fork_id) else {
return Ok(DeskResponse::NotStored(fork_unknown()));
};
if !showable(question, deciders) {
return Ok(DeskResponse::NotStored(fork_unknown()));
}
Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders)?, as_of: image.head })
Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders, decider)?, as_of: image.head })
}
}
@ -413,6 +511,7 @@ mod tests {
use super::*;
use crate::envelope::{decode_desk_request, encode_desk_request, encode_desk_response};
use crate::fixtures::*;
use chrono::TimeZone;
use crate::store::test_stores::MemoryStore;
use crate::{Faculty, PipelineAdmissionBatch, PipelineAdmissionOutcome};
use eureka_pipeline::{Line, PipelineRef, QuestionOption};
@ -506,7 +605,7 @@ mod tests {
}
fn all(mind: &Mind<MemoryStore>, n: u8) -> ForkList {
page(mind.desk_forks(&list(&LIST), None, None, limit(n))).0
page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(n))).0
}
// ---- the reads, one snapshot ----
@ -517,7 +616,7 @@ mod tests {
let stale = answer(&mut mind, "x", &LIST, &fork(1), &"0".repeat(64), None);
let DeskResponse::Answered { answer: stale, .. } = stale else { panic!("a stale answer is stored") };
assert_eq!(stale.code, Some(ForkAnswerCode::StaleFork));
let (shown, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (shown, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!(shown.card.options_hash, hash_of(&mind, &fork(1)));
assert_ne!(shown.card.options_hash, "0".repeat(64));
assert_eq!((shown.record.answers.len(), shown.record.counted_answer_id.clone()), (1, None));
@ -528,7 +627,7 @@ mod tests {
};
assert_eq!((counted.outcome.as_str(), counted.answer_id.as_str()), ("Counted", "n2"));
assert_eq!(counted_answer_id.as_deref(), Some("n2"));
let (after, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
let names: Vec<_> = after.record.answers.iter().map(|answer| answer.answer_id.as_str()).collect();
assert_eq!(names, ["n1", "n2"], "answers in admission order, named n<seq>");
@ -537,23 +636,23 @@ mod tests {
#[test]
fn list_then_read_at_one_stamp() {
let mut mind = desk(3);
let (first, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(2)));
let (first, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
assert_eq!(as_of, head(&mind));
assert_eq!(first.stamp, format!("s{as_of}"));
assert_eq!(first.mind, INSTANCE);
assert_eq!(ids(&first), [fork(3), fork(2)]);
let next = first.next_cursor.expect("one more fork waits");
assert_eq!(next.order(), first.forks[1].card.order);
let (second, _) = page(mind.desk_forks(&list(&LIST), None, Some(next), limit(2)));
let (second, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(next), limit(2)));
assert_eq!((ids(&second), second.next_cursor), (vec![fork(1)], None));
// The read at that stamp is the entry the list carried.
let (read, read_at) = entry(mind.desk_fork(&list(&LIST), &fork(2)));
let (read, read_at) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2)));
assert_eq!(read_at, as_of);
assert_eq!(read, first.forks[1]);
// Writing moves the stamp, and only then.
let hash = hash_of(&mind, &fork(2));
answer(&mut mind, "x", &LIST, &fork(2), &hash, None);
let (moved, moved_at) = page(mind.desk_forks(&list(&LIST), None, None, limit(2)));
let (moved, moved_at) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
assert_eq!((moved.stamp.clone(), moved_at), (format!("s{}", head(&mind)), head(&mind)));
assert_ne!(moved.stamp, first.stamp);
assert_eq!(moved.forks[1].record.answers.len(), 1);
@ -572,17 +671,17 @@ mod tests {
assert_eq!(ForkCursor::new(0), None, "no cursor names rank 0: nothing is below it");
for (at, expected) in [(0, 3), (1, 2), (2, 1)] {
let below = ForkCursor::new(orders[at]).unwrap();
let (rest, _) = page(mind.desk_forks(&list(&LIST), None, Some(below), limit(50)));
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(below), limit(50)));
assert_eq!(rest.forks.len(), expected, "below order {}", orders[at]);
assert!(rest.forks.iter().all(|entry| entry.card.order < orders[at]));
}
// A page that stops short names the last order it returned.
let (short, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(1)));
let (short, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
assert_eq!((ids(&short), short.next_cursor.map(ForkCursor::order)), (vec![fork(4)], Some(orders[0])));
// A page that ends exactly on the last fork has no cursor.
let (exact, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(4)));
let (exact, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(4)));
assert_eq!((exact.forks.len(), exact.next_cursor), (4, None));
let (over, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50)));
let (over, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!(over.forks.len(), 4);
}
@ -595,7 +694,7 @@ mod tests {
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] {
for size in [0, 1, 50] {
let (shown, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(size)));
let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
assert_eq!((shown.open_count, shown.recorded_count), expected, "{status:?} {cursor:?} {size}");
assert!(shown.forks.len() <= size as usize);
for entry in &shown.forks {
@ -609,31 +708,31 @@ mod tests {
}
}
}
let (open, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Open), None, limit(50)));
let (open, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50)));
assert_eq!(ids(&open), [fork(5), fork(3), fork(2)]);
let (recorded, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Recorded), None, limit(50)));
let (recorded, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50)));
assert_eq!(ids(&recorded), [fork(4), fork(1)]);
// A cursor and a status together: the cards below the cursor that have the status.
let below_four = ForkCursor::new(recorded.forks[0].card.order);
let (rest, _) = page(mind.desk_forks(&list(&LIST), Some(ListStatus::Open), below_four, limit(50)));
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50)));
assert_eq!(ids(&rest), [fork(3), fork(2)]);
}
#[test]
fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() {
let mind = desk(3);
let (counts, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(0)));
let (counts, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(0)));
assert_eq!((counts.forks.len(), counts.next_cursor), (0, None));
assert_eq!((counts.open_count, counts.recorded_count), (3, 0));
assert_eq!((counts.stamp, as_of), (format!("s{}", head(&mind)), head(&mind)));
let (one, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(1)));
let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
assert_eq!(one.forks.len(), 1, "limit 1 is one entry, not none");
}
#[test]
fn a_desk_with_no_forks_is_an_empty_page() {
let mind = seeded();
let (empty, as_of) = page(mind.desk_forks(&list(&LIST), None, None, limit(10)));
let (empty, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(10)));
assert_eq!((empty.open_count, empty.recorded_count, empty.forks.len(), empty.next_cursor), (0, 0, 0, None));
assert_eq!(empty.stamp, format!("s{as_of}"));
}
@ -658,11 +757,11 @@ mod tests {
let shown = all(&mind, 50);
assert_eq!(ids(&shown), [fork(2), fork(1)]);
assert_eq!((shown.open_count, shown.recorded_count), (2, 0), "the count is over the showable set");
unknown(mind.desk_fork(&list(&LIST), &unshowable));
unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), &unshowable));
// No decider, nothing is shown at all: the gate needs one.
let (none, _) = page(mind.desk_forks(&[], None, None, limit(50)));
let (none, _) = page(mind.desk_forks(&s(LIST[0]), &[], None, None, limit(50)));
assert_eq!((none.forks.len(), none.open_count, none.recorded_count), (0, 0, 0));
unknown(mind.desk_fork(&[], &fork(1)));
unknown(mind.desk_fork(&s(LIST[0]), &[], &fork(1)));
}
#[test]
@ -671,37 +770,235 @@ mod tests {
close(&mut mind, &fork(1), "R1");
let ruling_key = format!("{CAMPAIGN_ROOT}:ruling:R1");
for id in [fork(9).as_str(), "", "Q1", "no-colons", "eureka-state:question:", ruling_key.as_str(), &"x".repeat(5000), "other-campaign:question:Q1"] {
unknown(mind.desk_fork(&list(&LIST), id));
unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), id));
}
// And nothing was written by looking.
let before = head(&mind);
let _ = mind.desk_fork(&list(&LIST), &fork(1));
let _ = mind.desk_forks(&list(&LIST), None, None, limit(50));
let _ = mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1));
let _ = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50));
assert_eq!(head(&mind), before);
}
// ---- the trust boundary: the whole record, whoever asks ----
// ---- the reader's view: ids for a decider, counts for anyone else ----
#[test]
fn the_whole_record_comes_back_whoever_the_deciders_list_leaves_out() {
const ANNA: &str = "00000000-0000-4000-8000-0000000000a1";
const BEN: &str = "00000000-0000-4000-8000-0000000000b2";
const CORA: &str = "00000000-0000-4000-8000-0000000000c3";
const DAN: &str = "00000000-0000-4000-8000-0000000000d4";
/// Two forks. Fork 1: Anna answers, Ben revises over her, Cora (not on the call's list) is refused,
/// and Ben answers a second time (a retraction): four stored answers, three answerers.
fn answered_desk() -> Mind<MemoryStore> {
let mut mind = desk(2);
let hash = hash_of(&mind, &fork(1));
let note = "Keep it local: a slot mark is the author's call.";
// Y, a decider of that call, answers with a note; a stranger is refused with a note too.
answer(&mut mind, "y", &LIST, &fork(1), &hash, Some(note));
answer(&mut mind, "stranger", &LIST, &fork(1), &hash, Some("a refused person's words"));
// The reads carry another list that names neither of them: no call is handed a subject.
let narrow = list(&["x"]);
let (read, _) = entry(mind.desk_fork(&narrow, &fork(1)));
let notes: Vec<_> = read.record.answers.iter().map(|answer| (answer.answerer.as_str(), answer.note.as_deref())).collect();
assert_eq!(notes, [("y", Some(note)), ("stranger", Some("a refused person's words"))]);
let (listed, _) = page(mind.desk_forks(&narrow, None, None, limit(50)));
assert_eq!(listed.forks[1], read, "the list carries the same whole record");
// The list only fills the card.
assert_eq!(read.card.deciders, ["x"]);
let (wide, _) = entry(mind.desk_fork(&list(&["x", "y", "z"]), &fork(1)));
assert_eq!(wide.card.deciders, ["x", "y", "z"]);
assert_eq!(ForkEntry { card: ForkCard { deciders: read.card.deciders.clone(), ..wide.card.clone() }, ..wide }, read, "nothing else moved");
let deciders = [ANNA, BEN];
answer(&mut mind, ANNA, &deciders, &fork(1), &hash, Some("Anna's note"));
answer(&mut mind, BEN, &deciders, &fork(1), &hash, Some("Ben's note"));
answer(&mut mind, CORA, &deciders, &fork(1), &hash, Some("Cora's note"));
answer(&mut mind, BEN, &deciders, &fork(1), &hash, Some("Ben again"));
// Ben retracts Anna's first answer: a Retraction row, so the non-decider view must null it too.
let retraction = ForkAnswerRequest {
schema_version: RequestSchema::V0,
fork_id: fork(1),
options_hash: hash.clone(),
answer: None,
retracted: true,
retracts: Some("n1".into()),
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
mind.desk_answer(&list(&deciders), s(BEN), DeskAuthority::Member, retraction, now());
mind
}
fn reader(mind: &Mind<MemoryStore>, subject: &str, deciders: &[&str]) -> (ForkList, ForkEntry, Vec<u8>) {
let (page, _) = page(mind.desk_forks(&s(subject), &list(deciders), None, None, limit(50)));
let response = mind.desk_fork(&s(subject), &list(deciders), &fork(1));
let bytes = encode_desk_response("m", "fork", &response, "r").unwrap();
let bytes = cultnet_rs::encode_cultnet_message_to_vec(&bytes, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap();
let (entry, _) = entry(response);
(page, entry, bytes)
}
fn holds(bytes: &[u8], text: &str) -> bool {
bytes.windows(text.len()).any(|window| window == text.as_bytes())
}
/// Negative first: whoever is not a decider is told no account id, in the card, the record or the bytes.
#[test]
fn a_non_decider_is_told_no_account_id_only_counts() {
let mind = answered_desk();
let (listed, read, bytes) = reader(&mind, DAN, &[ANNA, BEN]);
for id in [ANNA, BEN, CORA, DAN] {
assert!(!holds(&bytes, id), "the read carries {id}");
let list_bytes = rmp_serde::to_vec_named(&listed).unwrap();
assert!(!holds(&list_bytes, id), "the list carries {id}");
}
for card in listed.forks.iter().map(|entry| &entry.card).chain([&read.card]) {
assert!(card.deciders.is_empty(), "withheld, never no deciders");
assert_eq!(card.decider_count, 2);
}
assert_eq!(read.record.answers.len(), 5);
assert!(read.record.answers.iter().any(|shown| shown.outcome == "Retraction"), "the retraction row is there and nulled too");
assert!(read.record.answers.iter().all(|shown| shown.answerer.is_none()), "the key is written, null");
assert_eq!(read.record.answerer_count, 3, "Anna, Ben and the refused Cora");
// The other fork has no answers: zero answerers.
assert_eq!(listed.forks.iter().find(|entry| entry.card.fork_id == fork(2)).unwrap().record.answerer_count, 0);
// Notes stay whole on the wire.
assert_eq!(read.record.answers[0].note.as_deref(), Some("Anna's note"));
}
#[test]
fn a_decider_is_told_the_ids_and_the_same_counts() {
let mind = answered_desk();
let (_, as_decider, _) = reader(&mind, ANNA, &[ANNA, BEN]);
let (_, as_other, _) = reader(&mind, DAN, &[ANNA, BEN]);
assert_eq!(as_decider.card.deciders, [ANNA, BEN]);
assert_eq!(as_decider.card.decider_count, 2);
let named: Vec<_> = as_decider.record.answers.iter().map(|shown| shown.answerer.as_deref().unwrap()).collect();
assert_eq!(named, [ANNA, BEN, CORA, BEN, BEN]);
assert_eq!(as_decider.record.answerer_count, 3);
// Nothing else differs between the two views.
let blind = ForkEntry {
card: ForkCard { deciders: Vec::new(), ..as_decider.card.clone() },
record: ForkRecord { answers: as_decider.record.answers.iter().map(|shown| ForkDeskAnswer { answerer: None, ..shown.clone() }).collect(), ..as_decider.record.clone() },
..as_decider
};
assert_eq!(blind, as_other);
}
#[test]
fn decider_means_subject_in_this_calls_deciders() {
let mind = answered_desk();
let (_, with, _) = reader(&mind, DAN, &[ANNA, DAN]);
assert_eq!(with.card.deciders, [ANNA, DAN]);
let (_, without, _) = reader(&mind, DAN, &[ANNA, BEN]);
assert!(without.card.deciders.is_empty());
// Exact, byte for byte: a longer or different-case subject is not a decider.
let (_, longer, _) = reader(&mind, &format!("{ANNA}0"), &[ANNA, BEN]);
assert!(longer.card.deciders.is_empty());
}
#[test]
fn the_answer_path_still_names_its_answerer() {
let mut mind = desk(1);
let hash = hash_of(&mind, &fork(1));
let DeskResponse::Answered { answer, .. } = answer(&mut mind, ANNA, &[ANNA], &fork(1), &hash, None) else { panic!("an answer") };
assert_eq!(answer.answerer.as_deref(), Some(ANNA));
}
// ---- raised_at: the question's first admission ----
fn raised_at(mind: &Mind<MemoryStore>, fork_id: &str) -> Option<String> {
entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at
}
#[test]
fn raised_at_is_the_first_admission_and_never_moves() {
let mut mind = seeded();
let first = chrono::Utc.with_ymd_and_hms(2026, 10, 1, 8, 30, 0).unwrap();
committed(admit_at(&mut mind, vec![question_n(1)], first));
assert_eq!(raised_at(&mind, &fork(1)).as_deref(), Some("2026-10-01T08:30:00Z"));
// Later admissions: an answer, a ruling that closes it, other questions.
let hash = hash_of(&mind, &fork(1));
answer(&mut mind, ANNA, &[ANNA], &fork(1), &hash, None);
close(&mut mind, &fork(1), "R1");
committed(admit(&mut mind, vec![question_n(2)]));
assert_eq!(raised_at(&mind, &fork(1)).as_deref(), Some("2026-10-01T08:30:00Z"));
assert_eq!(raised_at(&mind, &fork(2)).as_deref(), Some("2026-09-16T12:00:00Z"));
}
fn imported_question(mind: &mut Mind<MemoryStore>, committed_at: &str) -> String {
let source = |ordinal: u64, committed_at: &str| crate::receipt::ImportSource {
instance: slug(crate::fixtures::OTHER_INSTANCE),
receipt_id: s(&format!("mind-commit-source-{ordinal}")),
ordinal,
committed_at: s(committed_at),
faculty: Faculty::Imagination,
agent: s("imagination"),
session: s("source-session"),
tool: s("huginn"),
external: vec![],
};
let import = |source, documents: Vec<PipelineDocument>| PipelineAdmissionBatch {
instance: INSTANCE.into(),
provenance: crate::receipt::PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s("self"), session: s("import-1"), tool: s("huginn"), imported_from: Some(source) },
documents,
};
let D::Campaign(mut other) = campaign(&[REPO]) else { unreachable!() };
other.slug = slug("other-campaign");
committed(mind.admit(import(source(1, "2026-11-08T16:00:00Z"), vec![D::Campaign(other)]), now()));
let D::Question(mut doc) = question_n(7) else { unreachable!() };
doc.campaign = slug("other-campaign");
committed(mind.admit(import(source(2, committed_at), vec![D::Question(doc)]), now()));
"other-campaign:question:Q7".into()
}
#[test]
fn an_imported_question_keeps_its_source_time() {
let mut mind = seeded();
let id = imported_question(&mut mind, "2026-11-08T16:20:05+02:00");
assert_eq!(raised_at(&mind, &id).as_deref(), Some("2026-11-08T14:20:05Z"), "the source's time, in UTC, not the import's");
}
/// CultLib's instant has four year digits: a time whose UTC year leaves 0..=9999 is served nil.
#[test]
fn an_import_time_outside_year_0_to_9999_in_utc_is_nil() {
for committed_at in ["0000-01-01T00:00:00+05:00", "9999-12-31T23:59:59-05:00"] {
let mut mind = seeded();
let id = imported_question(&mut mind, committed_at);
assert_eq!(raised_at(&mind, &id), None, "{committed_at}");
}
let mut mind = seeded();
let id = imported_question(&mut mind, "9999-12-31T23:59:59Z");
assert_eq!(raised_at(&mind, &id).as_deref(), Some("9999-12-31T23:59:59Z"));
}
#[test]
fn an_unparseable_import_time_is_nil() {
let mut mind = seeded();
let id = imported_question(&mut mind, "last tuesday");
assert_eq!(raised_at(&mind, &id), None);
}
#[test]
fn raised_at_is_outside_options_hash() {
let mind = desk(1);
let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1)));
let moved = ForkCard { raised_at: Some("2020-01-01T00:00:00Z".into()), ..read.card.clone() };
assert_ne!(moved.raised_at, read.card.raised_at);
assert_eq!(moved.options_hash, read.card.options_hash);
assert_eq!(read.card.options_hash, hash_of(&mind, &fork(1)));
}
// ---- the card refuses a count that does not match ----
fn card_with(deciders: usize, decider_count: u64) -> Vec<u8> {
let mind = desk(1);
let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1)));
let ids: Vec<String> = (0..deciders).map(|n| format!("00000000-0000-4000-8000-{n:012x}")).collect();
rmp_serde::to_vec_named(&ForkCard { deciders: ids, decider_count, ..read.card }).unwrap()
}
#[test]
fn a_card_whose_deciders_do_not_match_its_count_is_refused() {
let decode = |bytes: &[u8]| rmp_serde::from_slice::<ForkCard>(bytes);
for (deciders, count) in [(2, 3), (3, 2), (1, 0), (0, 0), (0, 65), (65, 65), (2, 1)] {
assert!(decode(&card_with(deciders, count)).is_err(), "{deciders} ids, count {count}");
}
for (deciders, count) in [(0, 1), (0, 64), (1, 1), (2, 2), (64, 64)] {
assert!(decode(&card_with(deciders, count)).is_ok(), "{deciders} ids, count {count}");
}
}
#[test]
fn a_card_without_raised_at_is_refused() {
let bytes = card_with(1, 1);
let mut value: serde_json::Value = rmp_serde::from_slice(&bytes).unwrap();
value.as_object_mut().unwrap().remove("raised_at");
assert!(rmp_serde::from_slice::<ForkCard>(&rmp_serde::to_vec_named(&value).unwrap()).is_err());
value.as_object_mut().unwrap().insert("raised_at".into(), serde_json::Value::Null);
assert!(rmp_serde::from_slice::<ForkCard>(&rmp_serde::to_vec_named(&value).unwrap()).is_ok());
}
// ---- the projection ----
@ -718,7 +1015,7 @@ mod tests {
held.raised_in = Some(PipelineRef { kind: PipelineKind::Ruling, id: s("eureka-state:ruling:R0") });
committed(admit(&mut mind, vec![D::Ruling(ruling("R0"))]));
committed(admit(&mut mind, vec![shown]));
let (read, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
let card = &read.card;
assert_eq!((card.fork_id.as_str(), card.title.as_str(), card.context.as_str()), (fork(1).as_str(), "Share the mark?", "Should a slot mark be shared?"));
let options: Vec<_> = card.options.iter().map(|option| (option.id.as_str(), option.text.as_str())).collect();
@ -738,9 +1035,9 @@ mod tests {
fn a_ruling_closes_the_card_and_names_itself() {
let mut mind = desk(2);
close(&mut mind, &fork(1), "R1");
let (closed, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (closed, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!((closed.card.status, closed.card.ruling.as_deref()), (CardStatus::Closed, Some("eureka-state:ruling:R1")));
let (open, _) = entry(mind.desk_fork(&list(&LIST), &fork(2)));
let (open, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2)));
assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None));
}
@ -750,7 +1047,7 @@ mod tests {
let hash = hash_of(&mind, &fork(1));
answer(&mut mind, "x", &LIST, &fork(1), &hash, None);
answer(&mut mind, "y", &LIST, &fork(1), &hash, None);
let (read, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!(read.record.counted_answer_id.as_deref(), Some("n1"));
let outcomes: Vec<_> = read.record.answers.iter().map(|answer| (answer.outcome.as_str(), answer.of.as_deref())).collect();
assert_eq!(outcomes, [("Counted", None), ("Revision", Some("n1"))]);
@ -767,7 +1064,7 @@ mod tests {
};
let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now());
assert!(matches!(stored, DeskResponse::Answered { .. }));
let (after, _) = entry(mind.desk_fork(&list(&LIST), &fork(1)));
let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
}
@ -789,7 +1086,7 @@ mod tests {
fn entry_and_list_vectors_round_trip_byte_for_byte() {
let file = transport();
let entries = file["entry"]["valid"].as_array().unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries.len(), 8);
for row in entries {
let bytes = bytes_of(row["messagePackHex"].as_str().unwrap());
let decoded: ForkEntry = rmp_serde::from_slice(&bytes).unwrap();
@ -797,7 +1094,7 @@ mod tests {
assert_eq!(decoded, serde_json::from_value::<ForkEntry>(row["message"].clone()).unwrap(), "{}", row["name"]);
}
let scenarios = file["listScenarios"].as_array().unwrap();
assert_eq!(scenarios.len(), 6);
assert_eq!(scenarios.len(), 9);
let mut steps = 0;
for scenario in scenarios {
for step in scenario["steps"].as_array().unwrap() {
@ -808,7 +1105,7 @@ mod tests {
steps += 1;
}
}
assert_eq!(steps, 9);
assert_eq!(steps, 12);
}
/// CultLib f6c9bf5f: the short-page and too-large vectors. What Huginn can judge replays: the short
@ -1058,7 +1355,7 @@ mod tests {
fn pages(mind: &Mind<MemoryStore>, size: u8) -> Vec<(DeskResponse, ForkList)> {
let (mut cursor, mut out) = (None, Vec::new());
loop {
let response = mind.desk_forks(&list(&LIST), None, cursor, limit(size));
let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, cursor, limit(size));
let (page, _) = page(response.clone());
cursor = page.next_cursor;
out.push((response, page));
@ -1109,7 +1406,7 @@ mod tests {
}
mind
};
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&list(&LIST), None, None, limit(50))).0;
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))).0;
// The most forks that fit one page with the oldest one's text short.
let fits = (2..=50).take_while(|forks| first_page(&build(*forks, 300)).forks.len() == *forks as usize).last().expect("two fit");
assert!(fits < 50, "the desk outgrows a page");
@ -1120,18 +1417,18 @@ mod tests {
if first_page(&build(fits, mid)).forks.len() == fits as usize { low = mid } else { high = mid }
}
let (in_page, over) = (build(fits, low), build(fits, low + 1));
let held = page(in_page.desk_forks(&list(&LIST), None, None, limit(50)));
let held = page(in_page.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!((held.0.forks.len(), held.0.next_cursor), (fits as usize, None));
let size = bare(&DeskResponse::Forks { list: held.0, as_of: held.1 });
// The wire grows in steps of up to three bytes per text byte, so "exact" is within one step.
assert!(size <= DESK_PAGE_BUDGET && DESK_PAGE_BUDGET - size < 4, "{size} against {DESK_PAGE_BUDGET}");
let (moved, _) = page(over.desk_forks(&list(&LIST), None, None, limit(50)));
let (moved, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!(moved.forks.len(), fits as usize - 1, "one byte more and the oldest fork goes to the next page");
// The page it would have been, composed from the single reads, is past the budget.
let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() };
let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() };
assert!(bare(&DeskResponse::Forks { list: would_be, as_of: head(&over) }) > DESK_PAGE_BUDGET);
assert_eq!(moved.next_cursor.map(ForkCursor::order), moved.forks.last().map(|entry| entry.card.order));
let (rest, _) = page(over.desk_forks(&list(&LIST), None, moved.next_cursor, limit(50)));
let (rest, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50)));
assert_eq!(ids(&rest), [fork(1)]);
}
@ -1140,7 +1437,7 @@ mod tests {
let hash = hash_of(mind, &fork(n));
let note = "a long reason, ".repeat(60);
let mut added = 0;
while bare(&mind.desk_fork(&list(&LIST), &fork(n))) <= bytes {
while bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= bytes {
let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(&note)) else { panic!("the note was not stored") };
added += 1;
assert!(added < 200, "the record does not grow");
@ -1160,18 +1457,18 @@ mod tests {
let mut mind = desk(3);
grown_past(&mut mind, 2, DESK_PAGE_BUDGET);
// The newest fork pages alone: the oversize one would bust the page beside it.
let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50)));
let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!(ids(&first), [fork(3)]);
assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2));
let response = mind.desk_forks(&list(&LIST), None, first.next_cursor, limit(50));
let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50));
let DeskResponse::NotStored(refusal) = response else { panic!("{response:?}") };
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
let order = 1;
assert_eq!(refusal.error, fork_too_large(&fork(2), order).error);
assert!(refusal.error.contains(&fork(2)) && refusal.error.contains("order 1"), "{refusal:?}");
// The order the refusal names is the order of the fork it names; its cursor goes on past it.
assert_eq!(entry(mind.desk_fork(&list(&LIST), &fork(2))).0.card.order, order);
let (rest, _) = page(mind.desk_forks(&list(&LIST), None, ForkCursor::new(order), limit(50)));
assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order);
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50)));
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
}
@ -1202,13 +1499,13 @@ mod tests {
fn a_rank_is_stable_when_more_questions_are_admitted() {
let mut mind = seeded();
committed(admit(&mut mind, (1..=5).map(question_n).collect()));
let (first, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(2)));
let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
assert_eq!((orders(&first), first.next_cursor.map(ForkCursor::order)), (vec![4, 3], Some(3)));
let held = first.next_cursor;
let (before, _) = page(mind.desk_forks(&list(&LIST), None, held, limit(50)));
let (before, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
committed(admit(&mut mind, vec![question_n(6)]));
committed(admit(&mut mind, vec![question_n(0)]));
let (after, _) = page(mind.desk_forks(&list(&LIST), None, held, limit(50)));
let (after, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
assert_eq!((after.forks, after.next_cursor), (before.forks, before.next_cursor), "the cursor from before resumes the same forks");
let everything = all(&mind, 50);
assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]);
@ -1237,9 +1534,9 @@ mod tests {
admit_unshowable(&mut mind, "Q2");
committed(admit(&mut mind, vec![question_n(3)]));
for who in [&LIST[..], &["x"][..], &["z"][..]] {
let (shown, _) = page(mind.desk_forks(&list(who), None, None, limit(50)));
let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(who), None, None, limit(50)));
assert_eq!((ids(&shown), orders(&shown)), (vec![fork(3), fork(1)], vec![2, 0]), "{who:?}: rank 1 is the gap");
assert_eq!(entry(mind.desk_fork(&list(who), &fork(3))).0.card.order, 2, "{who:?}");
assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}");
}
committed(admit(&mut mind, vec![question_n(4)]));
assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]);
@ -1252,11 +1549,11 @@ mod tests {
let mut mind = desk(2);
for n in [1, 2] {
grown_past(&mut mind, n, DESK_PAGE_BUDGET * 55 / 100);
assert!(bare(&mind.desk_fork(&list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
assert!(bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
}
let (one, _) = page(mind.desk_forks(&list(&LIST), None, None, limit(50)));
let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!((ids(&one), one.next_cursor.map(ForkCursor::order)), (vec![fork(2)], Some(1)));
let (rest, _) = page(mind.desk_forks(&list(&LIST), None, one.next_cursor, limit(50)));
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50)));
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
}
@ -1287,9 +1584,9 @@ mod tests {
assert_eq!(after.len(), 70);
assert_eq!(after.iter().map(|(_, order)| *order).collect::<std::collections::BTreeSet<_>>(), (0..70).collect());
// The page boundary at c1: the page ending at order 1 names c1, and the next page is order 0 alone.
let (upper, _) = page(mind.desk_forks(&list(&LIST), None, ForkCursor::new(3), limit(2)));
let (upper, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2)));
assert_eq!((orders(&upper), upper.next_cursor.map(ForkCursor::order)), (vec![2, 1], Some(1)));
let (last, _) = page(mind.desk_forks(&list(&LIST), None, upper.next_cursor, limit(50)));
let (last, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50)));
assert_eq!((orders(&last), last.next_cursor), (vec![0], None));
}
@ -1373,7 +1670,7 @@ mod tests {
let mut mind = desk(3);
close(&mut mind, &fork(1), "R9");
close(&mut mind, &fork(2), "R2");
let named = |n: u32| entry(mind.desk_fork(&list(&LIST), &fork(n))).0.card.ruling;
let named = |n: u32| entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling;
assert_eq!(named(1).as_deref(), Some("eureka-state:ruling:R9"));
assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2"));
assert_eq!(named(3), None);
@ -1398,7 +1695,7 @@ mod tests {
assert!(fullest > DESK_PAGE_BUDGET - 3000, "the pages fill to the budget, not far short of it: {fullest}");
}
/// Libby's list scenarios (CultLib 9d6ee72e `listScenarios`) replayed through `desk_forks` over the
/// Libby's list scenarios (CultLib `listScenarios`, d3025d3c) replayed through `desk_forks` over the
/// data the Mind stores: a question for every order the scenario's cards hold, an unshowable one
/// (a rank nobody sees) for every order below the highest that none holds, the closed cards closed
/// by a ruling and the answers she records answered, the closed card's second one after it closed.
@ -1432,15 +1729,31 @@ mod tests {
if entry["card"]["status"] == "closed" {
close(&mut mind, &key, &format!("R{order}"));
}
for _ in answers.iter().skip(1) {
answer(&mut mind, "y", &LIST, &key, &hash, None);
for later in answers.iter().skip(1) {
if later["outcome"] == "Retraction" {
let retraction = ForkAnswerRequest {
schema_version: RequestSchema::V0,
fork_id: key.clone(),
options_hash: hash.clone(),
answer: None,
retracted: true,
retracts: later["retracts"].as_str().map(str::to_string),
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retraction, now());
} else {
answer(&mut mind, "y", &LIST, &key, &hash, None);
}
}
}
// A `twin` scenario is a non-decider's view: the reader is not on the call's list.
let subject = if scenario.get("twin").is_some() { "z" } else { "x" };
for step in scenario["steps"].as_array().unwrap() {
let (request, want) = (&step["request"], &step["response"]);
let status: Option<ListStatus> = serde_json::from_value(request["status"].clone()).unwrap();
let cursor: Option<ForkCursor> = serde_json::from_value(request["cursor"].clone()).unwrap();
let (got, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(request["limit"].as_u64().unwrap() as u8)));
let (got, _) = page(mind.desk_forks(&s(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8)));
let name = &scenario["name"];
assert_eq!((got.open_count, got.recorded_count), (want["open_count"].as_u64().unwrap(), want["recorded_count"].as_u64().unwrap()), "{name}");
assert_eq!(serde_json::to_value(got.next_cursor).unwrap(), want["next_cursor"], "{name}");
@ -1450,6 +1763,9 @@ mod tests {
assert_eq!(have.card.order, want["card"]["order"].as_u64().unwrap(), "{name}");
assert_eq!(serde_json::to_value(have.card.status).unwrap(), want["card"]["status"], "{name}");
assert_eq!(have.record.counted_answer_id.as_deref(), want["record"]["counted_answer_id"].as_str(), "{name}");
assert_eq!(serde_json::to_value(&have.card.deciders).unwrap().as_array().unwrap().len(), want["card"]["deciders"].as_array().unwrap().len(), "{name}: withheld or named");
assert_eq!(have.card.decider_count, want["card"]["decider_count"].as_u64().unwrap(), "{name}");
assert!(have.record.answers.iter().all(|answer| answer.answerer.is_some() == (subject == "x")), "{name}: answerers are named for a decider only");
let kept: Vec<_> = have.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.outcome.as_str())).collect();
let wanted: Vec<_> = want["record"]["answers"].as_array().unwrap().iter().map(|a| (a["answer_id"].as_str().unwrap(), a["outcome"].as_str().unwrap())).collect();
assert_eq!(kept, wanted, "{name}");
@ -1457,7 +1773,7 @@ mod tests {
replayed += 1;
}
}
assert_eq!(replayed, 9);
assert_eq!(replayed, 12);
}
/// `two_cards_share_one_order` is not a document the schema can refuse (CultLib: "not expressible in
@ -1486,7 +1802,7 @@ mod tests {
// Every third fork is closed; the batch's ranks follow its keys, so the closed ranks are read, not computed.
let mut closed = std::collections::BTreeSet::new();
for n in (1..=24).step_by(3) {
closed.insert(entry(mind.desk_fork(&list(&LIST), &fork(n))).0.card.order);
closed.insert(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.order);
close(&mut mind, &fork(n), &format!("R{n}"));
}
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
@ -1499,7 +1815,7 @@ mod tests {
for size in 1..=24_u8 {
let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0);
loop {
let (page, _) = page(mind.desk_forks(&list(&LIST), status, cursor, limit(size)));
let (page, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
pages += 1;
assert!(page.forks.len() <= size as usize && pages < 100);
walked.extend(orders(&page));

View file

@ -74,6 +74,19 @@ impl<'i> Docs<'i> {
self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| self.image.row(row).ordinal)
}
/// When the document was first admitted, in UTC: an imported document's source receipt
/// (`imported_from.committed_at`) wins over this mind's own `admitted_at`. `None` when the
/// document is not held at this snapshot or the time does not parse as RFC3339 (an import's
/// `committed_at` is only checked as non-empty text at admission). Also `None` when the UTC year is
/// outside 0..=9999: CultLib's instant pattern has four year digits, and a local time near the edge
/// can convert out of range.
pub(crate) fn first_admitted_at(&self, kind: PipelineKind, id: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let row = self.image.id_of(kind, id).filter(|row| self.visible(*row))?;
let admission = &self.image.row(row).view.admission;
let text = admission.provenance.imported_from.as_ref().map_or(admission.admitted_at.as_str(), |source| source.committed_at.0.as_str());
chrono::DateTime::parse_from_rfc3339(text).ok().map(|time| time.with_timezone(&chrono::Utc)).filter(|time| (0..=9999).contains(&chrono::Datelike::year(time)))
}
pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> {
self.in_batch(kind, id).or_else(|| self.in_image(kind, id))
}

View file

@ -549,7 +549,7 @@ mod tests {
answered_at: "2026-11-10T12:00:00Z".into(),
received_at: "2026-11-10T12:00:01Z".into(),
claim_in_future: false,
answerer: "user-7".into(),
answerer: Some("user-7".into()),
authority: DeskAuthority::Member,
request_id: "00000000-0000-4000-8000-000000000001".into(),
},
@ -623,7 +623,7 @@ mod tests {
answered_at: "2026-11-10T12:00:00.123456789+00:00".into(),
received_at: "2026-11-10T12:00:00.123456789+00:00".into(),
claim_in_future: true,
answerer: long(200),
answerer: Some(long(200)),
authority: DeskAuthority::Operator,
request_id: "00000000-0000-4000-8000-000000000001".into(),
};

View file

@ -3,9 +3,10 @@
//! are what the door speaks. `docs/desk-feed-door.md` is the contract they pin; the README in the
//! fixtures directory lists them.
//!
//! No fixture written here carries a card: Huginn's types do not carry `raised_at`, `decider_count`,
//! `answerer_count` or a nullable `answerer` yet. Those shapes are CultLib's own bytes, vendored at
//! one commit in `fixtures/desk-feed/cultlib-d3025d3c/` and checked below.
//! The entry-bearing fixtures carry the reader-count shapes (`raised_at`, `decider_count`,
//! `answerer_count`, a nullable `answerer`), a decider's view and a non-decider's. CultLib's own bytes
//! for those shapes are vendored at one commit in `fixtures/desk-feed/cultlib-d3025d3c/`; Huginn's
//! real types decode and re-encode every one of them byte for byte (checked below).
//!
//! `cargo test -p huginn-mind --test desk_feed_fixtures` compares every file with what the
//! encoders produce now. `HUGINN_WRITE_DESK_FIXTURES=1` rewrites them instead.
@ -17,8 +18,8 @@ use sha2::{Digest, Sha256};
use cultnet_rs::{CultNetMessage, CultNetWireContract, encode_cultnet_message_to_vec, encode_frame};
use huginn_mind::MindRefusal;
use huginn_mind::desk_wire::{
fork_too_large_read, AnswerSchema, DeskRequest, DeskResponse, ForkCursor, ForkDeskAnswer, ForkLimit, ForkList, ForkRecord, ForkRefusal, ForkRefusalCode,
ListSchema, ListStatus, RecordSchema,
fork_too_large_read, AnswerSchema, CardSchema, CardStatus, DeskRequest, DeskResponse, EntrySchema, ForkCard, ForkCursor, ForkDeskAnswer, ForkEntry, ForkLimit,
ForkList, ForkOption, ForkRecord, ForkRefusal, ForkRefusalCode, ForkRef, ListSchema, ListStatus, RecordSchema,
};
use huginn_mind::envelope::{OperationFailure, encode_desk_failure, encode_desk_request, encode_desk_response};
use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug};
@ -31,6 +32,8 @@ 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 member the call's deciders do not name, who reads as a non-decider.
const NON_DECIDER: &str = "9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4";
/// 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";
@ -54,6 +57,22 @@ fn read() -> DeskRequest {
DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(READER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() }
}
/// The same reads from a subject the call's deciders do not name: a non-decider.
fn forks_as_non_decider() -> DeskRequest {
DeskRequest::Forks {
mind: Slug("eureka".into()),
subject: s(NON_DECIDER),
deciders: vec![s(READER), s(OTHER_DECIDER)],
status: Some(ListStatus::Open),
cursor: None,
limit: ForkLimit::new(50).unwrap(),
}
}
fn read_as_non_decider() -> DeskRequest {
DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(NON_DECIDER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() }
}
fn list(open_count: u64, recorded_count: u64) -> DeskResponse {
DeskResponse::Forks {
list: ForkList {
@ -73,7 +92,7 @@ fn refusal(code: ForkRefusalCode, error: &str) -> DeskResponse {
DeskResponse::NotStored(ForkRefusal { schema_version: huginn_mind::desk_wire::RefusalSchema::V0, code, error: error.into() })
}
fn answer(id: &str, outcome: &str, of: Option<&str>, answerer: &str, note: &str, received_at: &str) -> ForkDeskAnswer {
fn answer(id: &str, outcome: &str, of: Option<&str>, answerer: Option<&str>, note: &str, received_at: &str) -> ForkDeskAnswer {
ForkDeskAnswer {
schema_version: AnswerSchema::V0,
answer_id: id.into(),
@ -88,35 +107,76 @@ fn answer(id: &str, outcome: &str, of: Option<&str>, answerer: &str, note: &str,
answered_at: received_at.into(),
received_at: received_at.into(),
claim_in_future: false,
answerer: answerer.into(),
answerer: answerer.map(str::to_string),
authority: DeskAuthority::Member,
request_id: format!("00000000-0000-4000-8000-00000000000{}", &id[1..]),
}
}
/// A fork's record with a counted answer and a decider's revision, answerer ids raw: the door sends
/// them raw and Bifrost masks them for a reader who is not a decider.
fn record() -> ForkRecord {
/// A fork's record with a counted answer, a decider's revision, a refusal and a retraction: four
/// answers by three accounts. A decider's view names every answerer; a non-decider's names none and
/// keeps the count.
fn record(decider: bool) -> ForkRecord {
let who = |id: &'static str| decider.then_some(id);
ForkRecord {
schema_version: RecordSchema::V0,
fork_id: FORK.into(),
options_hash: "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48".into(),
options_hash: HASH.into(),
counted_answer_id: Some("n1".into()),
answerer_count: 3,
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") },
answer("n1", "Counted", None, who(READER), "The asker lists her own forks.", "2026-11-11T17:00:00Z"),
answer("n2", "Revision", Some("n1"), who(OTHER_DECIDER), "Agreed.", "2026-11-11T17:05:00Z"),
ForkDeskAnswer { code: Some(huginn_mind::desk_wire::ForkAnswerCode::NotADecider), ..answer("n3", "Refused", None, who(OUTSIDER), "Me too.", "2026-11-11T17:06:00Z") },
// A second answer by an account already counted: four answers, three answerers.
ForkDeskAnswer {
retracts: Some("n2".into()),
choice: None,
note: None,
..answer("n4", "Retraction", None, OTHER_DECIDER, "", "2026-11-11T17:07:00Z")
..answer("n4", "Retraction", None, who(OTHER_DECIDER), "", "2026-11-11T17:07:00Z")
},
],
}
}
const HASH: &str = "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48";
/// The fork's card. A decider's view lists the call's ids; a non-decider's lists none and keeps the count.
fn card(decider: bool, raised_at: Option<&str>) -> ForkCard {
ForkCard {
schema_version: CardSchema::V0,
fork_id: FORK.into(),
title: "Who may list a fork?".into(),
context: "A fork is read by members only once it is listed.".into(),
options: vec![ForkOption { id: "asking-mind".into(), text: "The asking Mind lists her own forks.".into() }, ForkOption { id: "operator".into(), text: "An operator lists them.".into() }],
recommendation: "asking-mind".into(),
depends: Vec::new(),
leans: None,
flag: None,
raised_in: Some(ForkRef { kind: "Idea".into(), id: "eureka-substrate:idea:desk-door-map".into() }),
raised_at: raised_at.map(str::to_string),
deciders: if decider { vec![READER.into(), OTHER_DECIDER.into()] } else { Vec::new() },
decider_count: 2,
status: CardStatus::Open,
ruling: None,
ruled_from: Vec::new(),
order: 41,
options_hash: HASH.into(),
}
}
fn entry(decider: bool, raised_at: Option<&str>) -> ForkEntry {
ForkEntry { schema_version: EntrySchema::V0, card: card(decider, raised_at), record: record(decider) }
}
/// One open fork on the page; the counts are the page's visible set.
fn list_of(entry: ForkEntry) -> DeskResponse {
DeskResponse::Forks {
list: ForkList { schema_version: ListSchema::V0, mind: "eureka".into(), stamp: "s10190".into(), open_count: 1, recorded_count: 0, forks: vec![entry], next_cursor: None },
as_of: 10190,
}
}
/// The named MessagePack payload a CultNet operation message carries (base64 in the envelope).
fn payload(message: &CultNetMessage) -> Vec<u8> {
use base64::Engine;
@ -154,6 +214,13 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
message("list-open-cursor.request", encode_desk_request("m-2", &forks(Some(ListStatus::Open), Some(42), 50), None).unwrap());
message("list-counts.request", encode_desk_request("m-3", &forks(Some(ListStatus::Open), None, 0), None).unwrap());
message("read.request", encode_desk_request("m-4", &read(), None).unwrap());
message("list-non-decider.request", encode_desk_request("m-5", &forks_as_non_decider(), None).unwrap());
message("read-non-decider.request", encode_desk_request("m-6", &read_as_non_decider(), None).unwrap());
message("list-open.response", encode_desk_response("m-1", "forks", &list_of(entry(true, Some("2026-11-11T16:40:12Z"))), RUNTIME).unwrap());
message("list-open-non-decider.response", encode_desk_response("m-5", "forks", &list_of(entry(false, Some("2026-11-11T16:40:12Z"))), RUNTIME).unwrap());
// raised_at nil: the question's first admission is unknown.
message("read.response", encode_desk_response("m-4", "fork", &DeskResponse::Fork { entry: entry(true, None), as_of: 10190 }, RUNTIME).unwrap());
message("read-non-decider.response", encode_desk_response("m-6", "fork", &DeskResponse::Fork { entry: entry(false, None), as_of: 10190 }, RUNTIME).unwrap());
message("list-empty.response", encode_desk_response("m-1", "forks", &list(0, 0), RUNTIME).unwrap());
message("list-counts.response", encode_desk_response("m-3", "forks", &list(3, 5), RUNTIME).unwrap());
message(
@ -185,9 +252,11 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
encode_desk_failure("m-1", "forks", &OperationFailure { code: "payload-not-a-request".into(), message: "field Forks.limit: out-of-range".into() }, RUNTIME),
);
message("error.response", CultNetMessage::Error { error: "huginn.desk refuses a message_id over 256 bytes".into(), code: None, details: None });
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, decider) in [("record-whole", true), ("record-withheld", false)] {
let bytes = rmp_serde::to_vec_named(&record(decider)).unwrap();
files.push((format!("{name}.payload.msgpack"), bytes.clone()));
files.push((format!("{name}.payload.hex"), hex(&bytes).into_bytes()));
}
files
}
@ -232,7 +301,7 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
assert!(account_id(&subject.0), "subject");
assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders");
}
assert!(record().answers.iter().all(|answer| account_id(&answer.answerer)), "answerer");
assert!(record(true).answers.iter().all(|answer| account_id(answer.answerer.as_deref().unwrap())), "answerer");
for shape in [
"@heimdall:gamecult.org",
"meta@gamecult.org",
@ -503,28 +572,60 @@ impl Value {
}
}
/// Huginn's real types against CultLib's bytes. A decider's answers already decode (the answer keeps
/// its 16 keys). The card and the record do not: `ForkCard` has no `raised_at` or `decider_count`,
/// `ForkRecord` no `answerer_count`, and `ForkDeskAnswer.answerer` is a non-null string, and each type
/// refuses unknown keys. This pins the gap `cut-desk-feed-listing` closes; when it does, these
/// refusals become successful decodes of the same bytes and this test changes with them.
/// Huginn's real types against CultLib's bytes: each of the 14 vendored vectors decodes into the type
/// that names it and re-encodes to the same bytes.
#[test]
fn huginn_types_do_not_yet_carry_the_pinned_shapes() {
use huginn_mind::desk_wire::ForkCard;
for name in ["answer-counted", "answer-refused-stale"] {
rmp_serde::from_slice::<ForkDeskAnswer>(&vendored(name)).unwrap_or_else(|error| panic!("{name}: {error}"));
fn huginn_types_decode_and_re_encode_every_vendored_vector_byte_for_byte() {
fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(name: &str) {
let bytes = vendored(name);
let value = rmp_serde::from_slice::<T>(&bytes).unwrap_or_else(|error| panic!("{name}: {error}"));
assert!(rmp_serde::to_vec_named(&value).unwrap() == bytes, "{name} does not re-encode byte for byte");
}
fn refused<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> String {
rmp_serde::from_slice::<T>(bytes).err().expect("the real type refuses these bytes").to_string()
assert_eq!(VENDORED.len(), 14);
for (name, _) in VENDORED {
match name.split('-').next().unwrap() {
"card" => round_trip::<ForkCard>(name),
"answer" => round_trip::<ForkDeskAnswer>(name),
"entry" => round_trip::<ForkEntry>(name),
other => panic!("{name}: no type for {other}"),
}
}
}
/// Negative first: a non-decider's bytes hold no account id, in any fixture Huginn writes for that view.
#[test]
fn a_non_decider_fixture_holds_no_account_id() {
let ids = [READER, OTHER_DECIDER, OUTSIDER, NON_DECIDER];
let withheld = |bytes: &[u8]| ids.iter().filter(|id| holds(bytes, id)).count();
let files = fixtures();
let bytes_of = |name: &str| files.iter().find(|(file, _)| file == name).unwrap_or_else(|| panic!("no fixture {name}")).1.clone();
for name in ["list-open-non-decider.response.payload.msgpack", "read-non-decider.response.payload.msgpack", "record-withheld.payload.msgpack"] {
let bytes = bytes_of(name);
assert_eq!(withheld(&bytes), 0, "{name} carries an account id");
}
// The decider's twins do name them, so the search above can fail.
for name in ["list-open.response.payload.msgpack", "record-whole.payload.msgpack"] {
assert!(withheld(&bytes_of(name)) >= 3, "{name}");
}
}
/// The record header is `86` with `answerer_count` fifth, the card `DE 00 12` with `raised_at` and
/// `decider_count` where CultLib puts them, in Huginn's own bytes.
#[test]
fn huginns_own_card_and_record_bytes_have_the_pinned_headers_and_keys() {
let card_bytes = rmp_serde::to_vec_named(&card(true, Some("2026-11-11T16:40:12Z"))).unwrap();
assert_eq!(card_bytes[..3], [0xde, 0x00, 0x12]);
assert_eq!(decode(&card_bytes).keys(), CARD_KEYS);
check_card(&decode(&card_bytes), false);
let withheld_bytes = rmp_serde::to_vec_named(&card(false, None)).unwrap();
check_card(&decode(&withheld_bytes), true);
assert_eq!(decode(&withheld_bytes).get("raised_at"), &Value::Nil);
for decider in [true, false] {
let record_bytes = rmp_serde::to_vec_named(&record(decider)).unwrap();
assert_eq!(record_bytes[0], 0x86);
let decoded = decode(&record_bytes);
assert_eq!(decoded.keys(), RECORD_KEYS);
assert_eq!(decoded.get("answerer_count").int(), 3);
decoded.list_of_answers().iter().for_each(|answer| check_answer(answer, decider));
}
assert!(refused::<ForkCard>(&vendored("card-operator-fork")).contains("raised_at"));
assert!(refused::<ForkCard>(&vendored("card-withheld-deciders")).contains("raised_at"));
for name in ["answer-withheld-answerer", "answer-withheld-answerer-revision", "answer-withheld-answerer-retraction"] {
assert!(refused::<ForkDeskAnswer>(&vendored(name)).contains("string"), "{name}");
}
// The record is the entry's last key: its bytes run from the `86` after the key to the end.
let entry = vendored("entry-open-counted");
let key = [&[0xa6u8][..], b"record", &[0x86]].concat();
let at = entry.windows(key.len()).position(|window| window == key).expect("the record key") + key.len() - 1;
assert!(refused::<ForkRecord>(&entry[at..]).contains("answerer_count"));
}

View file

@ -25,6 +25,12 @@ lands (see `docs/desk-feed-door.md`).
| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 |
| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) |
| `read.request` | Bifrost to Huginn | `Fork` for one fork id |
| `list-non-decider.request` | Bifrost to Huginn | `Forks` as above, from a subject who is not on the call's deciders |
| `read-non-decider.request` | Bifrost to Huginn | `Fork`, from a subject who is not on the call's deciders |
| `list-open.response` | Huginn to Bifrost | a decider's page of one open fork: card with `raised_at` a string, `decider_count` 2 and both ids, record with `answerer_count` 3 and every `answerer` named |
| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes |
| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil |
| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id |
| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` |
| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` |
| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) |
@ -35,10 +41,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 all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole: what the door sends today (5 keys, no `answerer_count`) |
| `record-whole` | fragment | `gamecult.fork.record.v0` in a decider's view: all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole, `answerer_count` 3 (6 keys) |
| `record-withheld` | fragment | the same record in a non-decider's view: every `answerer` nil, notes whole, `answerer_count` 3 |
The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are
CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned commit and
each vector). They are not Huginn's output: Huginn's types do not carry those shapes yet.
`cut-desk-feed-listing` and `cut-desk-feed-raised-at` replace them with entry-bearing fixtures the
encoders write, after decoding these bytes into the real types.
also CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned
commit and each vector). Huginn's types decode every one of them and re-encode it byte for byte;
the fixtures above are what Huginn's encoders write for the same shapes.

View file

@ -3,7 +3,7 @@
Source: `GameCult/CultLib` at commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118, on
`3a51bbab` #117, on `af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is
the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them
from Huginn's encoders: Huginn's own types do not carry these shapes yet (see
from Huginn's encoders: they are the reference Huginn's types are checked against (see
`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in
`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`.
@ -25,4 +25,4 @@ from Huginn's encoders: Huginn's own types do not carry these shapes yet (see
| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 |
The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own
(not `rmp_serde`), and then tries Huginn's real types.
(not `rmp_serde`), then decodes it into Huginn's real type and re-encodes it, byte for byte.

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
000001ca87a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351355a54686b4e324d3259693031595451354c54517a4f444974596a46684d43316d4f5755345a44646a4e6d49315954536f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479

View file

@ -0,0 +1 @@
<EFBFBD>¤Fork„¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idÙ0eureka-substrate:question:desk-listing-authority

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034

View file

@ -0,0 +1 @@
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034

File diff suppressed because one or more lines are too long

View file

@ -3,24 +3,22 @@
Status, 2026-11-11: the list and read wire described here is on `main` (`DeskRequest::Forks`,
`DeskRequest::Fork`, `DeskResponse::Forks`, `DeskResponse::Fork`; `crates/huginn-mind/src/desk_wire.rs`
and `desk_wire/reads.rs`; the socket is `crates/huginn-daemon/src/desk_door.rs`). No running daemon
binds a desk socket yet (follow-up `eureka-substrate:follow_up:desk-door-ops-root-run`). Two parts
are planned and not built: the card's `raised_at`, `decider_count` and the record's `answerer_count`
and nullable `answerer` (the shapes pinned below), and the listing gate that decides which forks a
reader sees. They are cut specs `cut-desk-feed-raised-at` and `cut-desk-feed-listing` in the
`eureka-substrate` campaign.
binds a desk socket yet (follow-up `eureka-substrate:follow_up:desk-door-ops-root-run`). The reader
shapes are built: the card's `raised_at` and `decider_count`, the record's `answerer_count`, the
nullable `answerer`, and the withholding of account ids from a non-decider (below). One part is
planned and not built: the listing gate that decides which forks a reader sees (cut spec
`cut-desk-feed-listing` in the `eureka-substrate` campaign).
**Not live: the listing gate.** On `main` today every showable question is returned to every
reader, whatever the call's `deciders` say (pinned by
`the_whole_record_comes_back_whoever_the_deciders_list_leaves_out` in `desk_wire/reads.rs`).
The default of not listed, described under Listing below, holds only once `cut-desk-feed-listing`
lands. Until then, neither Bifrost's receiver nor the ops root-run (`desk-door-ops-root-run`) may
go live on a Mind that holds private questions.
reader, whatever the call's `deciders` say. The default of not listed, described under Listing below,
holds only once `cut-desk-feed-listing` lands. Until then, neither Bifrost's receiver nor the ops
root-run (`desk-door-ops-root-run`) may go live on a Mind that holds private questions.
This page is the wire contract for the consumer, Bifrost's `UnixSocketDeskDoor` (Bifrost PR #67,
`docs/desk-feed-design.md`). The golden bytes are in `crates/huginn-mind/tests/fixtures/desk-feed/`.
Huginn's own encoders write them (`crates/huginn-mind/tests/desk_feed_fixtures.rs`), so a decoder
that reads them reads the door. The reader-count shapes are CultLib's bytes, vendored in
`cultlib-d3025d3c/` (below). The document shapes inside the payload (`gamecult.fork.list.v0`,
`cultlib-d3025d3c/` (below); Huginn's types decode and re-encode every vendored vector byte for byte. The document shapes inside the payload (`gamecult.fork.list.v0`,
`gamecult.fork.entry.v0`, `gamecult.fork.card.v0`, `gamecult.fork.record.v0`,
`gamecult.desk_answer.v0`, `gamecult.fork.refusal.v0`) are CultLib's
(`contracts/cultnet/governance/fork-card.md`) and are not restated here.
@ -104,21 +102,19 @@ 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.r6`. The test
shape at decode is owed by `cut-desk-feed-listing`. The test
`every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use
synthetic UUIDs.
## Account ids by reader (shape pinned in CultLib; Huginn side planned)
## Account ids by reader
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.
byte; both values are in the account-id format above. Any other reader is a non-decider. The
reader is worked out per call and stored nowhere.
- **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 in force:
it, because the card is opaque to it. A decider sees the ids. The ruling is
`eureka-substrate:ruling:desk-read-visibility-withheld-ids`.
- **The shape**, pinned to CultLib commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118 "null-answerer vectors for
Revision and Retraction", additive over `3a51bbab` #117 "Fork card: decider_count, answerer_count,
@ -126,13 +122,16 @@ byte; both values are in the account-id format above. Any other reader is a non-
ruling `eureka-substrate:ruling:desk-reader-counts-shape`). The bytes are CultLib's own vectors
(`contracts/cultnet/governance/fork-card-vectors.json`, `messagePackHex`), vendored unedited in
`crates/huginn-mind/tests/fixtures/desk-feed/cultlib-d3025d3c/` (its README lists each file and
its vector name). Read off those bytes:
its vector name). Huginn's `ForkCard`, `ForkRecord`, `ForkDeskAnswer` and `ForkEntry` decode all
14 of them and re-encode them byte for byte. Read off those bytes:
- **Card:** 18 keys, header `DE 00 12` (map16): `schemaVersion` plus 17 fields, in this order:
`fork_id, title, context, options, recommendation, depends, leans, flag, raised_in, raised_at,
deciders, decider_count, status, ruling, ruled_from, order, options_hash`. `raised_at` is an
RFC3339 string or nil, always written. `decider_count` is an integer from 1 to 64 right after
`deciders`. An empty `deciders` means the ids are withheld from this reader, never "no
deciders"; a non-empty list holds exactly `decider_count` ids.
`deciders`: the length of the call's list, for every reader. An empty `deciders` means the ids
are withheld from this reader, never "no deciders"; a non-empty list holds exactly
`decider_count` ids. The card decoder refuses a count outside 1..64 or a non-empty list of
another length.
- **Record:** 6 keys, header `86`: `schemaVersion, fork_id, options_hash, counted_answer_id,
answerer_count, answers`, so `answerer_count` is the fifth key, right before `answers`. It is
the number of distinct answerers over ALL the fork's stored answers, refusals included, and is
@ -142,21 +141,19 @@ byte; both values are in the account-id format above. Any other reader is a non-
string or nil, nil only in a non-decider's view, with the key kept. The bytes pin a nil `answerer`
on all four variants, `Counted` (`answer-withheld-answerer`), `Revision`
(`answer-withheld-answerer-revision`), `Retraction` (`answer-withheld-answerer-retraction`) and
`Refused` (the withheld entries), beside each variant's string-`answerer` vector; ruling
`eureka-substrate:ruling:desk-reader-counts-shape` is the reason, no longer the only evidence. Stored
answers always carry it.
`Refused` (the withheld entries), beside each variant's string-`answerer` vector. Stored answers
always carry it, and so does the reply to an answer (`Answered`), which goes to the answerer.
- **Entry:** header `83` (`schemaVersion, card, record`). The view is read off `deciders`: a
non-empty `deciders` names every answerer, an empty one names none.
- `options_hash` does not change (still five members).
- **Fixtures:** Huginn's own encoders write entry-bearing fixtures for both views:
`list-open.response` and `read.response` (a decider's; `read.response` has `raised_at` nil),
`list-open-non-decider.response`, `read-non-decider.response` and `record-withheld`, with the
requests `list-non-decider.request` and `read-non-decider.request`. A test searches the
non-decider payloads' bytes for every account id and finds none.
- **Consumers:** Bifrost branch `heimdall/desk-card-repin` (`f977a43`) vendors Huginn's desk
fixtures from `79d954c` and needs a re-pin after this PR. Bifrost's `DeskDocuments.cs` marks
`answerer_count` `JsonRequired`, so its merge must wait for `cut-desk-feed-listing`: today's door
does not send the field.
- Huginn's real types do not carry these shapes yet: `ForkCard` has no `raised_at` or
`decider_count`, `ForkRecord` no `answerer_count`, `ForkDeskAnswer.answerer` is a non-null
string, and each type refuses unknown keys. The test
`huginn_types_do_not_yet_carry_the_pinned_shapes` pins that gap, and
`cut-desk-feed-listing` closes it by decoding these same bytes into the real types.
fixtures from `79d954c` and needs a re-pin to this change. Bifrost's `DeskDocuments.cs` marks
`answerer_count` `JsonRequired`; the door now sends it.
Response (`huginn.desk_response.v1`):
@ -207,7 +204,7 @@ An empty list is never served in place of a failure. `open_count: 0` means zero
## Card fields
`raised_at` is a required card key whose value is an RFC3339 string or nil (CultLib #115, `af914253`).
Nil means unknown. A string is the question's first-admission time, which never moves. It is outside
Nil means unknown. A string is the question's first-admission time, which never moves: a UTC `Z` instant at seconds resolution (the form CultLib requires), and nil when the time is unknown, does not parse, or its UTC year is outside 0..9999. 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 imported question the source mind's `committed_at` (`imported_from.committed_at`). A value that
@ -216,17 +213,15 @@ to C), C sees B's admission time, because an import quotes its source's receipt
own (follow-up `desk-raised-at-import-chain`).
The card's key order and bytes are pinned by the vendored CultLib vectors (above): `card-operator-fork`
(`raised_at` a string), `card-raised-at-nil` and `card-withheld-deciders`. The fixtures Huginn's own
encoders write carry no entry; entry-bearing list and read fixtures from Huginn's output are added by
`cut-desk-feed-raised-at` and `cut-desk-feed-listing`.
(`raised_at` a string), `card-raised-at-nil` and `card-withheld-deciders`, and Huginn's own entry-bearing
fixtures carry it (`list-open.response` a string, `read.response` nil).
## Trust boundary: what Huginn sends, what Bifrost does
- 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.
- Huginn sends each record with every answer's note (fixture `record-whole`, a decider's view) and
withholds the account ids from a non-decider (above, fixture `record-withheld`). Notes stay whole
on the wire.
- Bifrost owns three things: the per-request membership check, the `desk.read` token, and
stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer
ids.

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskRequest",
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `subject`\nand `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability\ngate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn\nanswers every read with the whole record and Bifrost applies visibility; see `DeskResponse`\nfor the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `deciders`\nfeed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider\nand is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost\nstrips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
"oneOf": [
{
"type": "object",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskResponse",
"description": "What the door answers one desk request with. `Answered` is a stored answer, refused ones\nincluded (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.\n`Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were\nall read at. `NotStored` is a request that stored nothing (an unknown fork included).\n`Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and\nHuginn writes no document for it.\n\nThe trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`\ncarry the WHOLE record, every answer's note included, to whatever the peer-checked socket\ncarries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a\nstripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable\nonly by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a\nsecond reader of that socket is a note leak.",
"description": "What the door answers one desk request with. `Answered` is a stored answer, refused ones\nincluded (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.\n`Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were\nall read at. `NotStored` is a request that stored nothing (an unknown fork included).\n`Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and\nHuginn writes no document for it.\n\nThe trust boundary of the reads (ruling `desk-read-visibility-withheld-ids`): `Forks` and `Fork`\ncarry every answer's note, to whatever the peer-checked socket carries; Bifrost strips notes\nat its door (a stripped read serves `note: null`, CultLib `fork-card.md`). Huginn itself\nwithholds the account ids from a reader who is not a decider (`deciders` empty, `answerer` nil,\ncounts in their place). Huginn must be reachable only by Bifrost's peer-checked socket: the\npeer-uid check IS the whole authentication, and a second reader of that socket is a note leak.",
"oneOf": [
{
"type": "object",
@ -157,7 +157,15 @@
"context": {
"type": "string"
},
"decider_count": {
"description": "How many deciders the call names, 1 to 64, whoever the reader is. A non-empty `deciders` holds exactly this many.",
"type": "integer",
"format": "uint64",
"maximum": 64,
"minimum": 1
},
"deciders": {
"description": "The call's decider ids for a decider; empty for any other reader (the ids are withheld, never \"no deciders\").",
"type": "array",
"items": {
"type": "string"
@ -193,6 +201,10 @@
"format": "uint64",
"minimum": 0
},
"raised_at": {
"description": "RFC3339 (UTC, seconds, `Z`) of the question's first admission, or nil when unknown. Outside `options_hash`.",
"type": "string"
},
"raised_in": {
"description": "A typed reference, as the question holds it (`raised_in`).",
"type": "object",
@ -244,7 +256,9 @@
"leans",
"flag",
"raised_in",
"raised_at",
"deciders",
"decider_count",
"status",
"ruling",
"ruled_from",
@ -263,6 +277,7 @@
"type": "string"
},
"answerer": {
"description": "The answering account, or nil in a non-decider's view (the key is kept). A stored answer always has one.",
"type": "string"
},
"authority": {
@ -421,6 +436,12 @@
"description": "`gamecult.fork.record.v0`: the answers in admission order and the derived counted answer.",
"type": "object",
"properties": {
"answerer_count": {
"description": "Distinct answerers over ALL the fork's stored answers, refusals included; the same for every reader.",
"type": "integer",
"format": "uint64",
"minimum": 0
},
"answers": {
"type": "array",
"items": {
@ -446,6 +467,7 @@
"fork_id",
"options_hash",
"counted_answer_id",
"answerer_count",
"answers"
]
},