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:
parent
b4f3a48a8f
commit
dcae9c5c2c
44 changed files with 6093 additions and 1694 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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(¬e)) 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));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.payload.msgpack
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-non-decider.request.payload.msgpack
vendored
Normal 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
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.frame.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.payload.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open-non-decider.response.payload.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.frame.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.payload.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/list-open.response.payload.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001ca87a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351355a54686b4e324d3259693031595451354c54517a4f444974596a46684d43316d4f5755345a44646a4e6d49315954536f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.payload.msgpack
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.request.payload.msgpack
vendored
Normal 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
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.frame.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.payload.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-non-decider.response.payload.msgpack
vendored
Normal file
Binary file not shown.
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read.response.frame.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/huginn-mind/tests/fixtures/desk-feed/read.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read.response.payload.hex
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read.response.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read.response.payload.msgpack
vendored
Normal file
Binary file not shown.
|
|
@ -1 +1 @@
|
|||
85ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
|
||||
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92463336131663265342d376236642d346538662d396130622d316332643365346635613662a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
|
||||
|
|
|
|||
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/record-withheld.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/record-withheld.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
86ad736368656d6156657273696f6eb767616d6563756c742e666f726b2e7265636f72642e7630a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438b1636f756e7465645f616e737765725f6964a26e31ae616e7377657265725f636f756e7403a7616e737765727394de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e31a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a7436f756e746564a26f66c0a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465be5468652061736b6572206c6973747320686572206f776e20666f726b732eab616e7377657265645f6174b4323032362d31312d31315431373a30303a30305aab72656365697665645f6174b4323032362d31312d31315431373a30303a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303031de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e32a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a85265766973696f6ea26f66a26e31a87265747261637473c0a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74167726565642eab616e7377657265645f6174b4323032362d31312d31315431373a30353a30305aab72656365697665645f6174b4323032362d31312d31315431373a30353a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303032de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e33a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65a752656675736564a26f66c0a87265747261637473c0a4636f6465ad6e6f745f615f64656369646572ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365ab61736b696e672d6d696e64a46e6f7465a74d6520746f6f2eab616e7377657265645f6174b4323032362d31312d31315431373a30363a30305aab72656365697665645f6174b4323032362d31312d31315431373a30363a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303033de0010ad736368656d6156657273696f6eb767616d6563756c742e6465736b5f616e737765722e7630a9616e737765725f6964a26e34a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a76f7574636f6d65aa52657472616374696f6ea26f66c0a87265747261637473a26e32a4636f6465c0ac6f7074696f6e735f68617368d94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438a663686f696365c0a46e6f7465c0ab616e7377657265645f6174b4323032362d31312d31315431373a30373a30305aab72656365697665645f6174b4323032362d31312d31315431373a30373a30305aaf636c61696d5f696e5f667574757265c2a8616e737765726572c0a9617574686f72697479a64d656d626572aa726571756573745f6964d92430303030303030302d303030302d343030302d383030302d303030303030303030303034
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/record-withheld.payload.msgpack
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/record-withheld.payload.msgpack
vendored
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue