Desk door: listing gate and account-id grammar at decode (PR B of cut-desk-feed-listing.r8; stacked on #62) #63

Open
eureka wants to merge 3 commits from eureka-substrate/desk-listing-gate into main
54 changed files with 833 additions and 240 deletions

View file

@ -32,7 +32,8 @@ sample.research eureka-state:research:p1.scope epiphany.pipeline.research.v2 Epi
sample.idea eureka-state:idea:p1.i1 epiphany.pipeline.idea.v2 EpiphanyPipelineIdeaDocument 9189a863616d706169676eac657572656b612d7374617465a470617373a27031a56c6162656ca26931a57469746c65b24f6e65206e6f74652070657220636c61696da469646561d9204b65657020612070617373206173206d616e7920736d616c6c206e6f7465732ea974726164656f666673b74d6f726520646f63756d656e747320746f206c6973742ea872657374735f6f6e9182a46b696e64a85265736561726368a26964be657572656b612d73746174653a72657365617263683a70312e73636f7065a6746f776172649182a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131ab70726f706f7365645f6f6eaa323032362d30392d3135
sample.reply yggdrasil:reply:mind-2026-10-09-1-r1 epiphany.pipeline.reply.v2 EpiphanyPipelineReplyDocument 9185a8696e7374616e6365a979676764726173696ca56c6162656cb46d696e642d323032362d31302d30392d312d7231a372756e82a46b696e64a352756ea26964bf79676764726173696c3a72756e3a6d696e642d323032362d31302d30392d31a7616e73776572739182a46b696e64a8466f6c6c6f775570a26964d923657572656b612d73746174653a666f6c6c6f775f75703a61736b2d7468652d73656c66a474657874d93054686520736861646572207061737320697320646f6e653b2074686520736f756e647320617265204c6962627927732e
sample.thought yggdrasil:thought:t1793000000 epiphany.pipeline.thought.v2 EpiphanyPipelineThoughtDocument 91de0014a8696e7374616e6365a979676764726173696ca56c6162656cab7431373933303030303030a474657874d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742ea572656164739182a46b696e64a64d656d6f7279a26964d92879676764726173696c3a6d656d6f72793a323032362d31302d3331743034313230302d74302d6d30af706572736f6e615f72656365697074b06d696e642d636f6d6d69742d30613162a86964656e74697479be457572656b612c207368652f6865722c20706c61696e2d73706f6b656e2ea7636f6e74657874d92056616c7565733a20636f686572656e6365206f7665722076656c6f636974792ea87374696d756c7573bd4c696262792061736b65642061626f75742074686520736f756e64732eaf70726f6a6563746f725f6d6f64656cb070726f6a6563746f723a6c6174657374aa70726f6a656374696f6ed92949206b656570207475726e696e67206f7665722077686f20686f6c64732074686520736f756e64732eaa626173655f6d6f64656cab626173653a6c6174657374b474656d70657261747572655f7065726d696c6c65cd03e8ae6d696e5f705f7065726d696c6c6532aa6d61785f746f6b656e73cca0aa63616e6469646174657392d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742eaf41207365636f6e642064726966742eb06e6561726573745f7065726d696c6c6592cd0136cd0366a663686f73656e00ac70726f6a6563746f725f6d73cd1068ac636f6e74696e7565725f6d73cd238cae70726f6d70745f76657273696f6ebd67686f73746c696768743d633433656235343b20776f7264733d313830
sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572a6757365722d37a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31
sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31
sample.desk_listing eureka-state:desk_listing:Q1 epiphany.pipeline.desk_listing.v2 EpiphanyPipelineDeskListingDocument 9183a863616d706169676eac657572656b612d7374617465a66c6973746564b8657572656b612d73746174653a7175657374696f6e3a5131a6726561736f6ed9324d656d62657273206d617920726561642077687920746865204d696e64206c6973747320697473206f776e20666f726b732e
resolution.superseded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581aa5375706572736564656481a262799282a46b696e64a652756c696e67a26964b6657572656b612d73746174653a72756c696e673a523882a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
resolution.fixed-by-none eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a26279c0a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a2627982a46b696e64a94375745265706f7274a26964d921657572656b612d73746174653a6375745f7265706f72743a6375742d33612e6831a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135

View file

@ -316,6 +316,10 @@ bounded_text! {
Words = 4000, words_text;
/// A client's retry token: a lowercase D-format UUID.
RequestId = 36, uuid_text;
/// A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a
/// handle is never one.
#[schemars(extend("pattern" = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))]
AccountId = 36, uuid_text;
/// A key label: `[A-Za-z0-9_-]{1,64}`.
Label = 64, label_text;
/// A campaign slug or key segment: label parts joined by `.`.
@ -885,8 +889,13 @@ value_types! {
pub struct PipelineDeskAnswer {
campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option<Short>, note: Option<Words>,
answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex,
answerer: Short, authority: DeskAuthority, outcome: DeskOutcome,
answerer: AccountId, authority: DeskAuthority, outcome: DeskOutcome,
}
/// A fork the Mind lists: members who are not on a call's decider list may read the question
/// while this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw
/// that). One per question, keyed by the question's label; the receipt says who listed it and
/// when, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.
pub struct PipelineDeskListing { campaign: Slug, listed: Short, reason: Line }
pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec<VerdictClaim>[64] }
pub struct PipelineFinding {
campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence,
@ -1425,6 +1434,16 @@ pipeline_kinds! {
"epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument";
DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer",
"epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument";
DeskListing(PipelineDeskListing) => EpiphanyPipelineDeskListingDocument, "desk_listing",
"epiphany.pipeline.desk_listing.v2", "EpiphanyPipelineDeskListingDocument";
}
impl AccountId {
/// Whether this is a lowercase D-format UUID. For the callers outside the crate, which have no way
/// to reach `Bounded`; on the pattern of `Slug::validate_slug`.
pub fn validate_account(&self) -> Result<(), PipelineRefusal> {
self.validate("account")
}
}
/// Parses a full document id: the grammar read backwards. A key and an id are
@ -1516,7 +1535,7 @@ const SUBJECT_LOCAL_MAX: usize = 64;
/// The longest kind name a resolution can name as its subject (`stewardship`),
/// checked against `PipelineKind::ALL` by a test.
const LONGEST_KIND_NAME: usize = "stewardship".len();
const LONGEST_KIND_NAME: usize = "desk_listing".len();
/// A `.n<sequence>` part at `u32::MAX`.
const SEQUENCE_PART_MAX: usize = ".n".len() + "4294967295".len();
@ -1651,6 +1670,10 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefus
let sequence = format!("n{}", value.sequence);
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &sequence])?)
}
D::DeskListing(value) => {
let label = parent_local("desk_listing.listed", &value.listed.0, &value.campaign.0, PipelineKind::Question)?;
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label])?)
}
D::Verdict(value) => {
let report = parent_local("verdict.cut_report", &value.cut_report.0, &value.campaign.0, PipelineKind::CutReport)?;
let cut = parent_cut("verdict.cut_report", report, 'h')?;
@ -1927,9 +1950,12 @@ mod tests {
campaign: slug(CAMPAIGN), fork: id("question", "Q1"), sequence: 1, options_hash: Sha256Hex("c".repeat(64)),
choice: Some(s("a")), note: Some("Share it, with the mark.".into()), answered_at: s("2026-11-10T15:00:00Z"),
received_at: s("2026-11-10T15:00:03Z"), claim_in_future: false, request_id: RequestId("6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13".into()), body_sha256: Sha256Hex("d".repeat(64)),
answerer: s("user-7"), authority: DeskAuthority::Member,
answerer: AccountId("5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f".into()), authority: DeskAuthority::Member,
outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") },
}), format!("{CAMPAIGN}:desk_answer:Q1.n1")),
(D::DeskListing(PipelineDeskListing {
campaign: slug(CAMPAIGN), listed: id("question", "Q1"), reason: "Members may read why the Mind lists its own forks.".into(),
}), format!("{CAMPAIGN}:desk_listing:Q1")),
]
}
@ -3587,7 +3613,7 @@ mod tests {
);
}
assert_eq!(widest, RESOLUTION_LOCAL_MAX, "the bound is exactly the widest depth-two chain");
assert_eq!(RESOLUTION_LOCAL_MAX, 111);
assert_eq!(RESOLUTION_LOCAL_MAX, 112);
}
#[test]
@ -4109,7 +4135,7 @@ mod tests {
};
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?;
let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf");
assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds");
assert_eq!(PipelineKind::ALL.len(), 22, "twenty-two kinds");
assert_eq!(
variants.iter().map(tag).collect::<Vec<_>>(),
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
@ -4639,7 +4665,7 @@ mod tests {
}
fn desk_answer_sample() -> PipelineDeskAnswer {
let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() };
let PipelineDocument::DeskAnswer(answer) = samples().remove(20).0 else { unreachable!() };
answer
}

View file

@ -20,7 +20,7 @@ macro_rules! kinds {
kinds!(
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship,
hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer,
hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer, desk_listing,
);
/// The schema named, or the names when none is: the exit code is 2 for a name

View file

@ -1145,12 +1145,12 @@ fn schema_prints_the_published_files() {
let names = run(&[], &["schema"], None);
assert_eq!(names.code, 0);
let names: Vec<&str> = names.out.lines().collect();
assert_eq!(names.len(), 22, "{names:?}");
assert_eq!(names.len(), 23, "{names:?}");
assert_eq!(names[0], "campaign");
assert_eq!(names[14], "pulse");
assert_eq!(names[15], "memory");
assert_eq!((names[16], names[17]), ("research", "idea"));
assert_eq!((names[18], names[19], names[20], names[21]), ("reply", "thought", "desk_answer", "selection"));
assert_eq!((names[18], names[19], names[20], names[21], names[22]), ("reply", "thought", "desk_answer", "desk_listing", "selection"));
for kind in PipelineKind::ALL {
assert!(names.contains(&kind.name()), "{}", kind.name());
}

View file

@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use cultnet_rs::Selection;
use crate::sources::forge::HttpForge;
use huginn_mind::desk_wire::{DeskRequest, DeskResponse};
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
use huginn_mind::eureka_pipeline::{AccountId, PipelineRef, Short, Slug};
use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
use huginn_mind::{ChatHits, ChatQuery, ChatWindow, ChatWindowQuery, Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
@ -112,6 +112,15 @@ impl Handled {
/// What the log says of the hits the mind held no document for: how many, and
/// the first few by name (a restored mind can leave hundreds), or nothing when
/// the mind held them all.
/// The door checks an account id; the Mind's desk calls take it as the text it is.
fn short_id(id: AccountId) -> Short {
Short(id.0)
}
fn short_ids(ids: &[AccountId]) -> Vec<Short> {
ids.iter().map(|id| Short(id.0.clone())).collect()
}
fn dropped_note(dropped: &[PipelineRef]) -> Option<String> {
if dropped.is_empty() {
return None;
@ -271,10 +280,10 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
}
match request {
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
self.mind.desk_answer(&deciders, subject, authority, request, now)
self.mind.desk_answer(&short_ids(&deciders), short_id(subject), authority, request, now)
}
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),
DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&short_id(subject), &short_ids(&deciders), status, cursor, limit),
DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&short_id(subject), &short_ids(&deciders), &fork_id),
}
}

View file

@ -85,20 +85,26 @@ fn wire(hash: &str, request_id: &str) -> ForkAnswerRequest {
}
}
/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID.
fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId {
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
}
const R1: &str = "00000000-0000-4000-8000-000000000001";
const R2: &str = "00000000-0000-4000-8000-000000000002";
const R3: &str = "00000000-0000-4000-8000-000000000003";
fn answer(who: &str, authority: DeskAuthority, deciders: &[&str], request: ForkAnswerRequest) -> DeskRequest {
DeskRequest::Answer { mind: slug(INSTANCE), subject: short(who), authority, deciders: deciders.iter().map(|d| short(d)).collect(), request }
DeskRequest::Answer { mind: slug(INSTANCE), subject: acct(who), authority, deciders: deciders.iter().map(|d| acct(d)).collect(), request }
}
/// A list read by `who` against `deciders`.
fn forks(who: &str, deciders: &[&str], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: u8) -> DeskRequest {
DeskRequest::Forks {
mind: slug(INSTANCE),
subject: short(who),
deciders: deciders.iter().map(|d| short(d)).collect(),
subject: acct(who),
deciders: deciders.iter().map(|d| acct(d)).collect(),
status,
cursor,
limit: ForkLimit::new(limit).unwrap(),
@ -110,7 +116,7 @@ fn forks_all(deciders: &[&str]) -> DeskRequest {
}
fn fork_read(who: &str, deciders: &[&str], fork_id: &str) -> DeskRequest {
DeskRequest::Fork { mind: slug(INSTANCE), subject: short(who), deciders: deciders.iter().map(|d| short(d)).collect(), fork_id: fork_id.into() }
DeskRequest::Fork { mind: slug(INSTANCE), subject: acct(who), deciders: deciders.iter().map(|d| acct(d)).collect(), fork_id: fork_id.into() }
}
fn page_of(response: DeskResponse) -> (ForkList, u64) {
@ -205,6 +211,13 @@ fn rig(peer_uid: u32) -> Rig {
}
impl Rig {
/// The Mind lists the seeded fork, on its own door, so a non-decider reaches it.
fn list_the_fork(&mut self) {
let listing = PipelineDocument::DeskListing(huginn_mind::eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: short(FORK), reason: "Listed.".into() });
let admitted = self.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![listing])), now()).answered();
assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}");
}
/// Runs `client` on its own thread while this one serves the door, then drains what is still waiting.
fn serving<T: Send>(&mut self, client: impl FnOnce(&Path) -> T + Send) -> T {
let path = self.path.clone();
@ -567,7 +580,7 @@ mod door_smoke {
claim_in_future: false,
request_id: RequestId(R1.into()),
body_sha256: Sha256Hex("0".repeat(64)),
answerer: short("x"),
answerer: acct("x"),
authority: DeskAuthority::Operator,
outcome: DeskOutcome::Counted,
});
@ -601,6 +614,7 @@ mod door_smoke {
#[test]
fn deciders_ride_each_request() {
let mut rig = rig(my_uid());
rig.list_the_fork();
let (before, hash) = (rig.state(), rig.hash.clone());
let out = rig.serving(|path| {
let ask = |id: &str, who: &str, deciders: &[&str], request_id: &str| {
@ -776,6 +790,7 @@ mod door_smoke {
#[test]
fn a_mind_refusal_and_a_stored_refusal_are_typed_replies() {
let mut rig = rig(my_uid());
rig.list_the_fork();
let (before, hash) = (rig.state(), rig.hash.clone());
let mut foreign = answer("x", DeskAuthority::Member, &["x"], wire(&hash, R1));
let DeskRequest::Answer { mind, .. } = &mut foreign else { unreachable!() };
@ -801,7 +816,7 @@ mod door_smoke {
assert_eq!((listed_at, read_at), (before.1 as u64, before.1 as u64), "both read at the head the mind held");
assert_eq!(list.stamp, format!("s{listed_at}"));
assert_eq!(list.forks[0], entry);
assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), ["x".to_string(), "y".to_string()].as_slice()));
assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), [acct("x").0, acct("y").0].as_slice()));
assert_eq!(rig.state(), before, "a read stores nothing");
assert!(rig.index.seen.lock().unwrap().is_empty());
// An unknown fork is the answer path's refusal.
@ -864,11 +879,19 @@ mod door_smoke {
let note = "Keep it local: the author's call.";
rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer {
mind: slug(INSTANCE),
subject: short("y"),
subject: acct("y"),
authority: DeskAuthority::Member,
deciders: vec![short("y")],
deciders: vec![acct("y")],
request: noted(&hash, R1, note),
})));
// Unlisted: the outsider is shown nothing at all, and a read is `fork_unknown`.
let (hidden, unknown) = rig.serving(|path| (reply_of(path, "m-1", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-1b", &fork_read("outsider", &["x"], FORK))));
assert_eq!(page_of(hidden).0.forks.len(), 0);
assert!(matches!(unknown, DeskResponse::NotStored(_)), "{unknown:?}");
// The Mind lists it, on its own door.
let listing = PipelineDocument::DeskListing(huginn_mind::eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: short(FORK), reason: "Members may read why.".into() });
let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![listing])), now()).answered();
assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}");
let (list, read) = rig.serving(|path| (reply_of(path, "m-2", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-3", &fork_read("outsider", &["x"], FORK))));
let (list, _) = page_of(list);
let (read, _) = entry_of(read);
@ -882,8 +905,8 @@ mod door_smoke {
}
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"));
assert_eq!(seen.card.deciders, [acct("x").0]);
assert_eq!(seen.record.answers[0].answerer.as_deref(), Some(acct("y").0.as_str()));
}
#[test]
@ -902,7 +925,7 @@ mod door_smoke {
let refused = rig.serving(|path| failure_of(path, &bytes));
assert_eq!(refused.code, "payload-not-a-request", "{what}");
assert!(refused.message.contains("deciders"), "{what}: {}", refused.message);
assert!(!refused.message.contains("d64"), "{what} echoes a decider: {}", refused.message);
assert!(!refused.message.contains(&acct("d64").0), "{what} echoes a decider: {}", refused.message);
}
assert_eq!(rig.state(), before, "nothing stored, head unchanged");
}

View file

@ -191,12 +191,18 @@ impl Drop for Daemon {
}
}
/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID.
fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId {
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
}
fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
DeskRequest::Answer {
mind: INSTANCE.into(),
subject: short(who),
subject: acct(who),
authority: DeskAuthority::Operator,
deciders: vec![short(who)],
deciders: vec![acct(who)],
request: ForkAnswerRequest {
schema_version: RequestSchema::V0,
fork_id: FORK.into(),
@ -213,8 +219,8 @@ fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest {
DeskRequest::Forks {
mind: INSTANCE.into(),
subject: short(who),
deciders: deciders.iter().map(|d| short(d)).collect(),
subject: acct(who),
deciders: deciders.iter().map(|d| acct(d)).collect(),
status: None,
cursor: None,
limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(),
@ -284,14 +290,14 @@ fn the_binary_serves_both_doors_and_survives_restart() {
};
assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.stamp.clone()), (1, 0, 1, format!("s{listed_at}")));
assert_eq!(list.forks[0].card.options_hash, hash);
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("x")], fork_id: FORK.into() })
else {
panic!("a read of the seeded fork")
};
assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at));
assert_eq!(daemon.documents(), seeded);
// `authority` is no field of a read: the payload is refused whole and nothing is answered or stored.
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })] {
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("x")], fork_id: FORK.into() })] {
let mut value = serde_json::to_value(&request).unwrap();
let variant = if operation == "forks" { "Forks" } else { "Fork" };
value[variant]["authority"] = serde_json::json!("Operator");
@ -319,16 +325,20 @@ 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, and is told no account id.
// A subject the call's deciders do not name is a non-decider: the fork is unlisted, so it reads as a missing one.
let outsider = ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("outsider"), deciders: vec![acct("y")], fork_id: FORK.into() });
assert!(matches!(outsider, DeskResponse::NotStored(ref refusal) if refusal.code == huginn_mind::desk_wire::ForkRefusalCode::ForkUnknown), "{outsider:?}");
// A decider reads both answers, the first with its choice and the stale one with its code, and is told who answered.
let DeskResponse::Fork { entry, .. } =
ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() })
ask(&desk, "m-r4", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("y"), deciders: vec![acct("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_deref(), answer.code)).collect();
assert_eq!(seen, [("n1", None, None), ("n2", None, Some(ForkAnswerCode::StaleFork))]);
let x = acct("x").0;
assert_eq!(seen, [("n1", Some(x.as_str()), None), ("n2", Some(x.as_str()), 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!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[acct("y").0][..], 1));
assert_eq!(daemon.documents(), stored);
// A stop takes the socket with it; the stored answers are the retry's memory.
@ -580,7 +590,7 @@ fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest {
}
fn fork_read(who: &str, fork_id: &str) -> DeskRequest {
DeskRequest::Fork { mind: INSTANCE.into(), subject: short(who), deciders: vec![short(who)], fork_id: fork_id.into() }
DeskRequest::Fork { mind: INSTANCE.into(), subject: acct(who), deciders: vec![acct(who)], fork_id: fork_id.into() }
}
/// The wire spelling of a not-stored refusal's code, and its text.

View file

@ -62,12 +62,18 @@ pub(crate) enum Door {
Desk,
}
/// Every staged `desk_answer`, whether the caller sent it or a derivation
/// produced it, is refused unless it came by the desk door.
fn refuse_desk_answers_outside_desk(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> {
match docs.batch.iter().find(|staged| staged.kind == PipelineKind::DeskAnswer) {
Some(staged) if door != Door::Desk => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }),
_ => Ok(()),
/// The door split. Every staged `desk_answer`, whether the caller sent it or a derivation produced it,
/// is refused unless it came by the desk door; and a staged `desk_listing` is refused if it came by the
/// desk door: listing is the Mind's own act, on the Admit door, with a receipt saying who.
fn refuse_documents_at_the_wrong_door(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> {
match docs.batch.iter().find(|staged| match staged.kind {
PipelineKind::DeskAnswer => door != Door::Desk,
PipelineKind::DeskListing => door == Door::Desk,
_ => false,
}) {
Some(staged) if staged.kind == PipelineKind::DeskListing => Err(MindRefusal::DeskRequestInvalid { field: "desk_listing".into() }),
Some(staged) => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }),
None => Ok(()),
}
}
@ -160,7 +166,7 @@ impl<S: MindStore> Mind<S> {
for envelope in envelopes {
docs.push(stage(envelope, &mind)?)?;
}
refuse_desk_answers_outside_desk(door, &docs)?;
refuse_documents_at_the_wrong_door(door, &docs)?;
// A6
let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1;
if self.is_empty() && !carries_instance {
@ -184,7 +190,7 @@ impl<S: MindStore> Mind<S> {
docs.push(stage(envelope, &mind)?)?;
}
}
refuse_desk_answers_outside_desk(door, &docs)?;
refuse_documents_at_the_wrong_door(door, &docs)?;
for staged in &docs.batch[originals..] {
resolve(&docs, staged, &mind, external, &mut strong)?;
}
@ -627,6 +633,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo
}
// Decided in `answer_desk` before the document exists; no other door can stage one.
D::DeskAnswer(_) => Ok(()),
// The question is a citation (`listed`), so a missing one is refused with every other missing referent, and
// the key derivation holds it to the listing's own campaign.
D::DeskListing(_) => Ok(()),
D::Ruling(ruling) => {
if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator {
return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() });
@ -905,6 +914,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
(K::Target, O::Superseded { by }) => all(by, K::Target),
(K::Question, O::Answered { by }) => by.kind == K::Ruling,
(K::Question, O::Withdrawn { .. }) => true,
(K::DeskListing, O::Withdrawn { .. }) => true,
(K::Ruling, O::Superseded { by }) => all(by, K::Ruling),
(K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec),
(K::CutSpec, O::Withdrawn { .. }) => true,

View file

@ -19,6 +19,7 @@
use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc};
use eureka_pipeline::{
AccountId,
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal,
PipelineResolution, RequestId, ResolutionOutcome, Sha256Hex, Short, Slug, Words,
};
@ -145,7 +146,7 @@ fn counts_under(answer: &PipelineDeskAnswer, current: &Sha256Hex) -> bool {
/// `answer_desk` runs before it reads the fork and the door runs before its showability gate.
pub(crate) fn bound_answer<'a>(docs: &'a Docs<'_>, answerer: &Short, request_id: &RequestId) -> Option<(&'a str, &'a PipelineDeskAnswer)> {
docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document {
PipelineDocument::DeskAnswer(held) if held.answerer == *answerer && held.request_id == *request_id => Some((key, held)),
PipelineDocument::DeskAnswer(held) if held.answerer.0 == answerer.0 && held.request_id == *request_id => Some((key, held)),
_ => None,
})
}
@ -314,7 +315,7 @@ impl<S: MindStore> Mind<S> {
claim_in_future,
request_id: request.request_id.clone(),
body_sha256: body_sha256.clone(),
answerer: stamp.answerer.clone(),
answerer: AccountId(stamp.answerer.0.clone()),
authority: stamp.authority,
outcome,
};
@ -425,7 +426,7 @@ mod tests {
const LIST: [&str; 6] = ["x", "y", "z", "a", "op", "dec"];
fn stamp_as(who: &str, authority: DeskAuthority, list: &[&str]) -> DeskStamp {
DeskStamp { answerer: s(who), authority, deciders: list.iter().map(|id| s(id)).collect() }
DeskStamp { answerer: account(who), authority, deciders: list.iter().map(|id| account(id)).collect() }
}
fn decider(who: &str) -> DeskStamp {
@ -531,7 +532,7 @@ mod tests {
let mut mind = desk();
stored(ask(&mut mind, &decider("z"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")));
stored(ask(&mut mind, &decider("a"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")));
assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, s("z"));
assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, acct("z"));
}
#[test]
@ -780,7 +781,7 @@ mod tests {
let record = held(&mind, &answer);
assert_eq!(record.outcome, outcome);
assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None });
assert_eq!((record.answerer, record.authority, record.answered_at), (s("guest"), DeskAuthority::Steward, s(at)));
assert_eq!((record.answerer, record.authority, record.answered_at), (acct("guest"), DeskAuthority::Steward, s(at)));
}
#[test]
@ -790,7 +791,7 @@ mod tests {
for (who, authority) in [("op", DeskAuthority::Operator), ("dec", DeskAuthority::Steward), ("x", DeskAuthority::Member)] {
let (answer, _) = stored(ask(&mut mind, &stamp_as(who, authority, &LIST), pick(FORK, "Q1", "A", at)));
let record = held(&mind, &answer);
assert_eq!((record.answerer, record.authority), (s(who), authority));
assert_eq!((record.answerer, record.authority), (acct(who), authority));
}
}
@ -1039,7 +1040,7 @@ mod tests {
let DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") };
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap();
assert_eq!(receipt.provenance.faculty, faculty, "{authority:?}");
assert_eq!(receipt.provenance.agent, s("x"));
assert_eq!(receipt.provenance.agent, account("x"));
}
}
@ -1206,10 +1207,10 @@ mod tests {
let image = first.image().unwrap();
let found = Docs::at(image, image.head).find(K::DeskAnswer, &key("Q1", 1)).cloned();
let Some(PipelineDocument::DeskAnswer(mut held)) = found else { panic!("stored") };
held.answerer = s(&long);
held.answerer = eureka_pipeline::AccountId(long.clone());
store.plant(PipelineDocument::DeskAnswer(held).prepare(&cache).unwrap());
let mut mind = opened(store, INSTANCE);
let stamp = stamp_as(&long, DeskAuthority::Member, &LIST);
let stamp = DeskStamp { answerer: s(&long), ..stamp_as("x", DeskAuthority::Member, &LIST) };
let before = head(&mind);
assert_eq!(replayed(ask(&mut mind, &stamp, request)), (key("Q1", 1), DeskOutcome::Counted));
assert_eq!(head(&mind), before);
@ -1262,8 +1263,8 @@ mod tests {
assert_eq!(stored(ask(&mut mind, &decider("x"), sent.clone())), (key("Q1", 1), DeskOutcome::Counted));
let (y_answer, y_outcome) = stored(ask(&mut mind, &decider("y"), sent.clone()));
assert_eq!((y_answer.as_str(), &y_outcome), (key("Q1", 2).as_str(), &DeskOutcome::Revision { of: s(&key("Q1", 1)) }));
assert_eq!(held(&mind, &y_answer).answerer, s("y"));
assert_eq!(held(&mind, &key("Q1", 1)).answerer, s("x"));
assert_eq!(held(&mind, &y_answer).answerer, acct("y"));
assert_eq!(held(&mind, &key("Q1", 1)).answerer, acct("x"));
// x's later retry still returns n1; y's returns n2.
assert_eq!(replayed(ask(&mut mind, &decider("x"), sent.clone())).0, key("Q1", 1));
assert_eq!(replayed(ask(&mut mind, &decider("y"), sent)).0, key("Q1", 2));

View file

@ -20,7 +20,7 @@ pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkE
use chrono::{DateTime, Utc};
use eureka_pipeline::{
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
AccountId, DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -232,18 +232,33 @@ pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARG
/// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
pub const DESK_DECIDERS_MAX: usize = 64;
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` subjects, none twice. A
/// bad list is the decoder's refusal, which names the field and never quotes a subject.
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> {
/// An account id read from the wire: a lowercase D-format UUID, or the decoder's refusal, which names the
/// field and never quotes the value.
fn account_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<AccountId, D::Error> {
let id = AccountId(String::deserialize(deserializer)?);
id.validate_account().map_err(|_| serde::de::Error::custom("not an account id: a lowercase D-format UUID"))?;
Ok(id)
}
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` account ids, none twice. A
/// bad list is the decoder's refusal, which names the field and never quotes an id.
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<AccountId>, D::Error> {
struct List;
/// One list entry, through the same grammar as `subject`.
struct Account(AccountId);
impl<'de> Deserialize<'de> for Account {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
account_id(deserializer).map(Self)
}
}
impl<'de> serde::de::Visitor<'de> for List {
type Value = Vec<Short>;
type Value = Vec<AccountId>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a list of distinct deciders")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Short>, A::Error> {
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<AccountId>, A::Error> {
let mut list = Vec::new();
while let Some(subject) = seq.next_element::<Short>()? {
while let Some(Account(subject)) = seq.next_element::<Account>()? {
if list.len() == DESK_DECIDERS_MAX {
return Err(serde::de::Error::custom("too many deciders"));
}
@ -264,7 +279,7 @@ fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Sho
fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "array",
"items": generator.subschema_for::<Short>(),
"items": generator.subschema_for::<AccountId>(),
"minItems": 1,
"maxItems": DESK_DECIDERS_MAX,
"uniqueItems": true
@ -279,8 +294,9 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
///
/// 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 `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
/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider,
/// sees every showable fork and is told the ids; any other reads only the forks the Mind lists
/// (a `desk_listing` in force) and is told counts in the ids' 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.
@ -289,19 +305,21 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
pub enum DeskRequest {
Answer {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "account_id")]
subject: AccountId,
authority: DeskAuthority,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
deciders: Vec<AccountId>,
request: ForkAnswerRequest,
},
Forks {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "account_id")]
subject: AccountId,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
deciders: Vec<AccountId>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
status: Option<ListStatus>,
@ -312,10 +330,11 @@ pub enum DeskRequest {
},
Fork {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "account_id")]
subject: AccountId,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
deciders: Vec<AccountId>,
fork_id: String,
},
}
@ -337,7 +356,7 @@ impl DeskRequest {
}
}
pub fn deciders(&self) -> &[Short] {
pub fn deciders(&self) -> &[AccountId] {
match self {
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
}
@ -530,10 +549,12 @@ impl<S: MindStore> Mind<S> {
};
let image = self.image()?;
let docs = Docs::at(image, image.head);
// A fork this reader may not see is unshowable here too, by the read side's own `visible`: the answer path
// must not tell a hidden fork from a missing one, nor store a refusal for it.
// A request already stored replays whatever the list is now: the lookup is `answer_desk`'s,
// asked here only so the gate does not shadow it.
let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none()
&& matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders));
&& matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders) || !reads::visible(&docs, &sent.fork.0, deciders.contains(&subject)));
if unshowable {
return Ok(DeskResponse::NotStored(fork_unknown()));
}
@ -597,6 +618,11 @@ mod tests {
fn desk() -> Mind<MemoryStore> {
let mut mind = seeded();
committed(admit(&mut mind, vec![question_n(1), question_n(2)]));
// Listed, so a reader who is not a decider reaches them (an unlisted fork is the reads' own tests').
for fork in [FORK, FORK2] {
let listing = D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: s(fork), reason: "Listed.".into() });
committed(admit(&mut mind, vec![listing]));
}
mind
}
@ -632,14 +658,14 @@ mod tests {
}
fn deciders(list: &[&str]) -> Vec<Short> {
list.iter().map(|who| s(who)).collect()
list.iter().map(|who| account(who)).collect()
}
/// The response and whether the call committed anything: a replay, a refused body and a gate
/// refusal store nothing, so the head does not move.
fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) {
let before = head(mind);
let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now());
let response = mind.desk_answer(&deciders(list), account(who), DeskAuthority::Member, request, now());
(response, head(mind) != before)
}
@ -779,7 +805,7 @@ mod tests {
request_id: RequestId(text("request_id").unwrap()),
body_sha256: Sha256Hex("0".repeat(64)),
// 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())),
answerer: AccountId(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())),
authority,
outcome,
};
@ -1355,9 +1381,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_deref().unwrap(), 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", account("y").0.as_str(), "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_deref().unwrap()), ("n1", "x"));
assert_eq!((original.answer_id.as_str(), original.answerer.as_deref().unwrap()), ("n1", account("x").0.as_str()));
// 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"] {
@ -1472,6 +1498,7 @@ mod tests {
assert_eq!(shown_hash(&question).0, her_hash, "{name}: the card's hash");
let mut mind = seeded();
committed(admit(&mut mind, vec![D::Question(question)]));
committed(admit(&mut mind, vec![D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: s(FORK), reason: "Listed.".into() })]));
let list: Vec<&str> = card["deciders"].as_array().unwrap().iter().map(|who| who.as_str().unwrap()).collect();
if card["status"] == "closed" {
close(&mut mind, FORK, "RQ1", "no");
@ -1568,27 +1595,27 @@ mod tests {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
// The operator's own account, off the list, is no decider; a member on the list is.
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
let off = mind.desk_answer(&deciders(&["x"]), account("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
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 on = mind.desk_answer(&deciders(&["m"]), account("m"), DeskAuthority::Member, wire(FORK, &hash), now());
let (answer, _, _) = answered(on);
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, "m"));
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, account("m").0.as_str()));
}
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----
use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
fn list_of(n: usize) -> Vec<Short> {
(0..n).map(|at| s(&format!("d{at}"))).collect()
fn list_of(n: usize) -> Vec<AccountId> {
(0..n).map(|at| acct(&format!("d{at}"))).collect()
}
/// Each request kind of the desk, carrying `list`.
fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] {
fn desk_requests(mind: &str, list: &[AccountId], hash: &str) -> [DeskRequest; 3] {
[
DeskRequest::Answer { mind: slug(mind), subject: s("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
DeskRequest::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
DeskRequest::Answer { mind: slug(mind), subject: acct("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
DeskRequest::Forks { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
DeskRequest::Fork { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
]
}
@ -1611,7 +1638,7 @@ mod tests {
// 64 deciders through to the Mind: the subject is listed and counts.
let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&desk_requests(INSTANCE, &list_of(64), &hash)[0]).unwrap() else { panic!("not an answer") };
let before = head(&mind);
let response = mind.desk_answer(&deciders, subject, authority, request, now());
let response = mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::<Vec<_>>(), s(&subject.0), authority, request, now());
assert!(head(&mind) > before);
assert_eq!(answered(response).0.outcome, "Counted");
@ -1631,7 +1658,7 @@ mod tests {
let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK));
let mut twice = list_of(3);
twice.push(s("d1"));
twice.push(acct("d1"));
for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
for sent in desk_requests(INSTANCE, &list, &hash) {
let failure = through_the_door(&sent).unwrap_err();
@ -1667,6 +1694,6 @@ mod tests {
// The same request to the mind it names is answered.
let DeskRequest::Answer { mind: named, subject, authority, deciders, request } = through_the_door(&desk_requests(INSTANCE, &list_of(4), &hash)[0]).unwrap() else { panic!("not an answer") };
mind.require_instance(&named).unwrap();
assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now())).0.outcome, "Counted");
assert_eq!(answered(mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::<Vec<_>>(), s(&subject.0), authority, request, now())).0.outcome, "Counted");
}
}

View file

@ -352,6 +352,18 @@ fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion,
}
}
/// Whether a reader may see this fork at all: a decider sees every showable fork (they must, to answer
/// it); anyone else only while the Mind's `desk_listing` for it is in force (derived from the
/// resolutions on every read; no status is stored). A fork nobody may see reads as a missing one.
pub(super) fn visible(docs: &Docs<'_>, key: &str, decider: bool) -> bool {
decider || {
let (root, label) = root_and_local(key);
let listing = format!("{root}:desk_listing:{label}");
// `in_force` is true of a document nobody wrote: a listing counts only if the Mind holds it.
docs.in_image(PipelineKind::DeskListing, &listing).is_some() && docs.in_force(PipelineKind::DeskListing, &listing)
}
}
/// Every question the Mind holds at the snapshot, showable or not, with its rank: the 0-based position
/// in ascending (admission ordinal, key) order. A rank is unique, and admissions only append, so a held
/// question's rank never moves; a question the deciders do not show leaves a gap and shifts no other.
@ -427,15 +439,17 @@ 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'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).
/// The call's `deciders` feed the showability gate; the call's `subject` decides which forks are visible and
/// which view of each entry is built. A decider sees every showable fork, with ids; any other reader sees a
/// showable fork only while the Mind's `desk_listing` for it is in force, with counts. The counts, the
/// pages and the cursor are over the visible set; `order` and `stamp` are not (a non-decider sees gaps).
/// 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. The view is the reader's, as `desk_forks`.
/// question, is not showable or is not visible to the reader is `fork_unknown`, the answer path's refusal, with the same bytes as a missing fork. 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)
}
@ -444,7 +458,7 @@ impl<S: MindStore> Mind<S> {
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();
let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, key, question)| showable(question, deciders) && visible(&docs, key, decider)).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;
let recorded_count = shown.len() as u64 - open_count;
@ -499,7 +513,7 @@ impl<S: MindStore> Mind<S> {
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) {
if !showable(question, deciders) || !visible(&docs, key, decider) {
return Ok(DeskResponse::NotStored(fork_unknown()));
}
Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders, decider)?, as_of: image.head })
@ -536,7 +550,7 @@ mod tests {
}
fn list(who: &[&str]) -> Vec<Short> {
who.iter().map(|id| s(id)).collect()
who.iter().map(|id| account(id)).collect()
}
fn head(mind: &Mind<MemoryStore>) -> u64 {
@ -568,7 +582,7 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now())
mind.desk_answer(&list(deciders), account(who), DeskAuthority::Member, request, now())
}
fn close(mind: &mut Mind<MemoryStore>, fork: &str, label: &str) {
@ -605,7 +619,7 @@ mod tests {
}
fn all(mind: &Mind<MemoryStore>, n: u8) -> ForkList {
page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(n))).0
page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(n))).0
}
// ---- the reads, one snapshot ----
@ -616,7 +630,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(&s(LIST[0]), &list(&LIST), &fork(1)));
let (shown, _) = entry(mind.desk_fork(&account(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));
@ -627,7 +641,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(&s(LIST[0]), &list(&LIST), &fork(1)));
let (after, _) = entry(mind.desk_fork(&account(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>");
@ -636,23 +650,23 @@ mod tests {
#[test]
fn list_then_read_at_one_stamp() {
let mut mind = desk(3);
let (first, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
let (first, as_of) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, Some(next), limit(2)));
let (second, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), &fork(2)));
let (read, read_at) = entry(mind.desk_fork(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
let (moved, moved_at) = page(mind.desk_forks(&account(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);
@ -671,17 +685,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(&s(LIST[0]), &list(&LIST), None, Some(below), limit(50)));
let (rest, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
let (short, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(4)));
let (exact, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(4)));
assert_eq!((exact.forks.len(), exact.next_cursor), (4, None));
let (over, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
let (over, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
assert_eq!(over.forks.len(), 4);
}
@ -694,7 +708,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(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
let (shown, _) = page(mind.desk_forks(&account(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 {
@ -708,31 +722,31 @@ mod tests {
}
}
}
let (open, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50)));
let (open, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50)));
let (recorded, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50)));
let (rest, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(0)));
let (counts, as_of) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
let (one, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(10)));
let (empty, as_of) = page(mind.desk_forks(&account(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}"));
}
@ -757,11 +771,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(&s(LIST[0]), &list(&LIST), &unshowable));
unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), &unshowable));
// No decider, nothing is shown at all: the gate needs one.
let (none, _) = page(mind.desk_forks(&s(LIST[0]), &[], None, None, limit(50)));
let (none, _) = page(mind.desk_forks(&account(LIST[0]), &[], None, None, limit(50)));
assert_eq!((none.forks.len(), none.open_count, none.recorded_count), (0, 0, 0));
unknown(mind.desk_fork(&s(LIST[0]), &[], &fork(1)));
unknown(mind.desk_fork(&account(LIST[0]), &[], &fork(1)));
}
#[test]
@ -770,12 +784,12 @@ 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(&s(LIST[0]), &list(&LIST), id));
unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), id));
}
// And nothing was written by looking.
let before = head(&mind);
let _ = mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1));
let _ = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50));
let _ = mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1));
let _ = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50));
assert_eq!(head(&mind), before);
}
@ -790,6 +804,9 @@ mod tests {
/// and Ben answers a second time (a retraction): four stored answers, three answerers.
fn answered_desk() -> Mind<MemoryStore> {
let mut mind = desk(2);
// Listed: a reader who is not a decider sees these forks (and their counts) at all.
list_it(&mut mind, 1);
list_it(&mut mind, 2);
let hash = hash_of(&mind, &fork(1));
let deciders = [ANNA, BEN];
answer(&mut mind, ANNA, &deciders, &fork(1), &hash, Some("Anna's note"));
@ -812,8 +829,8 @@ mod tests {
}
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 (page, _) = page(mind.desk_forks(&account(subject), &list(deciders), None, None, limit(50)));
let response = mind.desk_fork(&account(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);
@ -890,7 +907,7 @@ mod tests {
// ---- 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
entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at
}
#[test]
@ -964,18 +981,213 @@ mod tests {
#[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 (read, _) = entry(mind.desk_fork(&account(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 listing gate: a non-decider reads a fork only while the Mind lists it ----
fn listing_doc(n: u32) -> D {
D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN_ROOT), listed: s(&fork(n)), reason: "Members may read why.".into() })
}
fn listing_ref(n: u32) -> PipelineRef {
PipelineRef { kind: PipelineKind::DeskListing, id: s(&format!("{CAMPAIGN_ROOT}:desk_listing:Q{n}")) }
}
fn list_it(mind: &mut Mind<MemoryStore>, n: u32) {
committed(admit(mind, vec![listing_doc(n)]));
}
/// Unlist: a `Withdrawn` resolution on the listing, `sequence` its per-subject sequence.
fn unlist(mind: &mut Mind<MemoryStore>, n: u32, sequence: u32) -> String {
let withdrawal = crate::fixtures::resolution_n(listing_ref(n), sequence, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "unlisted".into() });
let key = eureka_pipeline::pipeline_key(&withdrawal).unwrap();
committed(admit(mind, vec![withdrawal]));
key
}
/// Relist: withdraw the withdrawal.
fn relist(mind: &mut Mind<MemoryStore>, withdrawal: &str) {
let subject = PipelineRef { kind: PipelineKind::Resolution, id: s(withdrawal) };
committed(admit(mind, vec![crate::fixtures::resolution(subject, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "listed again".into() })]));
}
fn seen_by(mind: &Mind<MemoryStore>, subject: &str) -> Vec<String> {
ids(&page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, None, limit(50))).0)
}
fn read_by(mind: &Mind<MemoryStore>, subject: &str, n: u32) -> DeskResponse {
mind.desk_fork(&account(subject), &list(&[ANNA, BEN]), &fork(n))
}
/// Negative first: with nothing listed, a non-decider is shown nothing, and a read of a real fork
/// is the same bytes as a read of one that does not exist.
#[test]
fn an_unlisted_fork_is_invisible_to_a_non_decider() {
let mind = desk(3);
let (shown, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(50)));
assert_eq!((shown.forks.len(), shown.open_count, shown.recorded_count, shown.next_cursor), (0, 0, 0, None));
let (counts, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(0)));
assert_eq!((counts.open_count, counts.recorded_count), (0, 0));
let real = read_by(&mind, DAN, 1);
unknown(real.clone());
assert_eq!(real, read_by(&mind, DAN, 99), "a hidden fork reads as a missing one");
let bytes = |response: &DeskResponse| rmp_serde::to_vec_named(response).unwrap();
assert_eq!(bytes(&real), bytes(&read_by(&mind, DAN, 99)));
}
#[test]
fn a_decider_sees_unlisted_forks() {
let mut mind = desk(3);
list_it(&mut mind, 2);
assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]);
assert_eq!(seen_by(&mind, DAN), [fork(2)]);
// Listing is not read for a decider: unlisting changes nothing for them.
unlist(&mut mind, 2, 1);
assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]);
assert!(seen_by(&mind, DAN).is_empty());
}
#[test]
fn listing_flips_and_is_audited() {
let mut mind = desk(2);
let mut states = vec![seen_by(&mind, DAN)];
let before = head(&mind);
list_it(&mut mind, 1);
states.push(seen_by(&mind, DAN));
let withdrawal = unlist(&mut mind, 1, 1);
states.push(seen_by(&mind, DAN));
relist(&mut mind, &withdrawal);
states.push(seen_by(&mind, DAN));
unlist(&mut mind, 1, 2);
states.push(seen_by(&mind, DAN));
let q1 = vec![fork(1)];
assert_eq!(states, [vec![], q1.clone(), vec![], q1, vec![]]);
assert_eq!(head(&mind), before + 4, "every flip is a document with its own receipt");
assert!(matches!(read_by(&mind, DAN, 1), DeskResponse::NotStored(_)));
}
#[test]
fn counts_and_cursor_cover_the_visible_set() {
let mut mind = desk(5);
for n in [2, 4] {
list_it(&mut mind, n);
}
close(&mut mind, &fork(4), "R4");
let list_for = |subject: &str, cursor, size| page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, cursor, limit(size))).0;
let counts = list_for(DAN, None, 0);
assert_eq!((counts.open_count, counts.recorded_count), (1, 1));
// Pages are gapless over the visible forks and the cursor never names a hidden one.
let first = list_for(DAN, None, 1);
assert_eq!(ids(&first), [fork(4)]);
let cursor = first.next_cursor.expect("one more visible fork");
let second = list_for(DAN, Some(cursor), 1);
assert_eq!((ids(&second), second.next_cursor), (vec![fork(2)], None));
// Order keeps its rank among ALL the Mind's questions: the gap is visible, the text is not.
assert_eq!(orders(&first).into_iter().chain(orders(&second)).collect::<Vec<_>>(), [3, 1]);
}
#[test]
fn a_listing_needs_a_question_of_its_campaign() {
let mut mind = desk(1);
let missing = refusal(admit(&mut mind, vec![listing_doc(9)]));
assert!(matches!(missing, MindRefusal::MissingReference { kind: PipelineKind::Question, .. }), "{missing:?}");
let D::DeskListing(mut other) = listing_doc(1) else { unreachable!() };
other.campaign = slug("other-campaign");
let before = head(&mind);
assert!(matches!(admit(&mut mind, vec![D::DeskListing(other)]), PipelineAdmissionOutcome::Refused(_)));
assert_eq!(head(&mind), before);
}
/// The answer path is no oracle: a non-decider answering a hidden fork gets the bytes of answering
/// one that never existed, and nothing is stored. A listed fork still stores the NotADecider refusal.
#[test]
fn a_non_decider_answering_a_hidden_fork_learns_nothing_and_stores_nothing() {
let mut mind = desk(2);
let hash = hash_of(&mind, &fork(1));
let before = (head(&mind), stored_answers(&mind));
let hidden = answer(&mut mind, DAN, &[ANNA, BEN], &fork(1), &hash, None);
let missing = answer(&mut mind, DAN, &[ANNA, BEN], &fork(99), &hash, None);
unknown(hidden.clone());
assert_eq!(rmp_serde::to_vec_named(&hidden).unwrap(), rmp_serde::to_vec_named(&missing).unwrap());
assert_eq!((head(&mind), stored_answers(&mind)), before, "nothing stored");
list_it(&mut mind, 1);
let listed = answer(&mut mind, DAN, &[ANNA, BEN], &fork(1), &hash, None);
let DeskResponse::Answered { answer: refused, .. } = listed else { panic!("a listed fork stores the refusal: {listed:?}") };
assert_eq!(refused.code, Some(ForkAnswerCode::NotADecider));
assert_eq!(stored_answers(&mind), before.1 + 1);
}
fn stored_answers(mind: &Mind<MemoryStore>) -> usize {
let image = mind.image().unwrap();
Docs::at(image, image.head).of_kind(PipelineKind::DeskAnswer).count()
}
#[test]
fn the_desk_door_cannot_admit_a_listing() {
let mut mind = desk(1);
let envelope = crate::fixtures::prepare(&listing_doc(1));
let instance = mind.instance().clone();
let outcome = mind.admit_steps(crate::admission::Door::Desk, &instance, provenance(Faculty::Operator), vec![envelope], now());
assert_eq!(outcome.unwrap_err(), MindRefusal::DeskRequestInvalid { field: "desk_listing".into() });
assert!(seen_by(&mind, DAN).is_empty());
}
#[test]
fn listed_role_is_carried_by_desk_listing_only() {
use crate::docs::CitationRole as R;
assert_eq!((R::Listed.carrier(), R::Listed.target_kinds(), R::Listed.name()), (PipelineKind::DeskListing, vec![PipelineKind::Question], "listed"));
assert_eq!(R::Fork.carrier(), PipelineKind::DeskAnswer);
assert_eq!(R::ALL.len(), 27);
}
// ---- the account grammar at decode ----
#[test]
fn a_decider_or_subject_outside_the_account_grammar_is_refused_at_decode() {
let good = encode_desk_request("m", &DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }, None).unwrap();
decode_desk_request(&good).unwrap();
let bad_ids = ["@x:server", "meta@gamecult.org", "someone", "7", &acct("a").0.to_uppercase(), &format!("{{{}}}", acct("a").0), ""];
for bad in bad_ids {
for (field, edit) in [("subject", 0), ("deciders", 1)] {
let mut value = serde_json::to_value(DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }).unwrap();
if edit == 0 {
value["Fork"]["subject"] = bad.into();
} else {
value["Fork"]["deciders"] = json!([bad]);
}
let sent = encode_desk_request_value(&value);
let failure = decode_desk_request(&sent).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{field}: {bad:?}");
assert!(failure.message.contains(field), "{}", failure.message);
assert!(bad.is_empty() || !failure.message.contains(bad), "the refusal quotes {bad:?}: {}", failure.message);
}
}
}
fn encode_desk_request_value(value: &Value) -> cultnet_rs::CultNetMessage {
use base64::Engine;
cultnet_rs::CultNetMessage::OperationRequest {
message_id: "m".into(),
service_id: DESK_SERVICE_ID.into(),
operation: "fork".into(),
payload_schema: DESK_REQUEST_SCHEMA.into(),
payload_encoding: "messagepack-base64".into(),
payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(value).unwrap()),
source_runtime_id: None,
target_runtime_id: None,
}
}
// ---- 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 (read, _) = entry(mind.desk_fork(&account(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()
}
@ -1015,7 +1227,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(&s(LIST[0]), &list(&LIST), &fork(1)));
let (read, _) = entry(mind.desk_fork(&account(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();
@ -1024,7 +1236,7 @@ mod tests {
assert_eq!(card.depends, ["eureka-state:question:other", "a prose line"]);
assert_eq!(card.raised_in, Some(ForkRef { kind: "Ruling".into(), id: "eureka-state:ruling:R0".into() }));
assert_eq!((card.leans.clone(), card.flag.clone(), card.status, card.ruling.clone(), card.ruled_from.clone()), (None, None, CardStatus::Open, None, vec![]));
assert_eq!(card.deciders, LIST);
assert_eq!(card.deciders, LIST.iter().map(|id| account(id).0).collect::<Vec<_>>());
assert_eq!(card.options_hash, hash_of(&mind, &fork(1)));
assert_eq!(card.order, 0, "the only question is rank 0");
assert_eq!(read.record.fork_id, fork(1));
@ -1035,9 +1247,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(&s(LIST[0]), &list(&LIST), &fork(1)));
let (closed, _) = entry(mind.desk_fork(&account(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(&s(LIST[0]), &list(&LIST), &fork(2)));
let (open, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2)));
assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None));
}
@ -1047,7 +1259,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(&s(LIST[0]), &list(&LIST), &fork(1)));
let (read, _) = entry(mind.desk_fork(&account(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"))]);
@ -1062,9 +1274,9 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now());
let stored = mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retract, now());
assert!(matches!(stored, DeskResponse::Answered { .. }));
let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
let (after, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
}
@ -1236,7 +1448,7 @@ mod tests {
let file = transport();
let flat = |message: &Value| {
json!({ "Forks": {
"mind": message["mind"], "subject": "s", "deciders": ["s"],
"mind": message["mind"], "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"],
"status": message["status"], "cursor": message["cursor"], "limit": message["limit"]
}})
};
@ -1261,7 +1473,7 @@ mod tests {
}
fn forks_json(status: Value, cursor: Value, limit: Value) -> Value {
json!({ "Forks": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "status": status, "cursor": cursor, "limit": limit } })
json!({ "Forks": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "status": status, "cursor": cursor, "limit": limit } })
}
#[test]
@ -1308,7 +1520,7 @@ mod tests {
#[test]
fn a_read_that_names_an_authority_is_not_a_request() {
let mut forks = forks_json(Value::Null, Value::Null, json!(5));
let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "fork_id": fork(1) } });
let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "fork_id": fork(1) } });
assert!(serde_json::from_value::<DeskRequest>(forks.clone()).is_ok() && serde_json::from_value::<DeskRequest>(fork.clone()).is_ok());
for authority in [json!("Operator"), json!("Member"), Value::Null] {
forks["Forks"]["authority"] = authority.clone();
@ -1317,7 +1529,7 @@ mod tests {
assert!(serde_json::from_value::<DeskRequest>(fork.clone()).is_err(), "Fork with {authority}");
}
// And through the envelope the door reads.
let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: s("s"), deciders: list(&["s"]), status: None, cursor: None, limit: limit(5) };
let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: acct("s"), deciders: vec![acct("s")], status: None, cursor: None, limit: limit(5) };
let message = encode_desk_request("m-1", &typed, None).unwrap();
assert_eq!(decode_desk_request(&message).unwrap().1, typed);
}
@ -1355,7 +1567,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(&s(LIST[0]), &list(&LIST), None, cursor, limit(size));
let response = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, cursor, limit(size));
let (page, _) = page(response.clone());
cursor = page.next_cursor;
out.push((response, page));
@ -1406,7 +1618,7 @@ mod tests {
}
mind
};
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))).0;
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&account(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");
@ -1417,18 +1629,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(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
let held = page(in_page.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
let (moved, _) = page(over.desk_forks(&account(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(&s(LIST[0]), &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(&account(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(&s(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50)));
let (rest, _) = page(over.desk_forks(&account(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50)));
assert_eq!(ids(&rest), [fork(1)]);
}
@ -1437,7 +1649,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(&s(LIST[0]), &list(&LIST), &fork(n))) <= bytes {
while bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= bytes {
let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(&note)) else { panic!("the note was not stored") };
added += 1;
assert!(added < 200, "the record does not grow");
@ -1457,18 +1669,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(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
let (first, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50));
let response = mind.desk_forks(&account(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(&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!(entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order);
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50)));
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
}
@ -1499,13 +1711,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(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
let (first, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
let (before, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
let (after, _) = page(mind.desk_forks(&account(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)]);
@ -1534,9 +1746,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(&s(LIST[0]), &list(who), None, None, limit(50)));
let (shown, _) = page(mind.desk_forks(&account(who[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(&s(LIST[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}");
assert_eq!(entry(mind.desk_fork(&account(who[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]);
@ -1549,11 +1761,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(&s(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
assert!(bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
}
let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
let (one, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50)));
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50)));
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
}
@ -1584,9 +1796,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(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2)));
let (upper, _) = page(mind.desk_forks(&account(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(&s(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50)));
let (last, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50)));
assert_eq!((orders(&last), last.next_cursor), (vec![0], None));
}
@ -1670,7 +1882,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(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling;
let named = |n: u32| entry(mind.desk_fork(&account(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);
@ -1721,6 +1933,9 @@ mod tests {
}
for (order, entry) in &held {
let key = fork(*order as u32);
if scenario.get("twin").is_some() {
list_it(&mut mind, *order as u32);
}
let answers = entry["record"]["answers"].as_array().unwrap();
let hash = hash_of(&mind, &key);
if !answers.is_empty() {
@ -1741,7 +1956,7 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retraction, now());
mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retraction, now());
} else {
answer(&mut mind, "y", &LIST, &key, &hash, None);
}
@ -1753,7 +1968,7 @@ mod tests {
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(&s(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8)));
let (got, _) = page(mind.desk_forks(&account(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}");
@ -1802,7 +2017,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(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.order);
closed.insert(entry(mind.desk_fork(&account(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)] {
@ -1815,7 +2030,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(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
let (page, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), status, cursor, limit(size)));
pages += 1;
assert!(page.forks.len() <= size as usize && pages < 100);
walked.extend(orders(&page));

View file

@ -401,7 +401,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option<Lineage<'_>> {
| D::Pulse(_)
| D::Reply(_)
| D::Thought(_)
| D::DeskAnswer(_) => None,
| D::DeskAnswer(_)
| D::DeskListing(_) => None,
}
}
@ -455,10 +456,11 @@ pub enum CitationRole {
DayThrough,
Fork,
Answer,
Listed,
}
impl CitationRole {
pub const ALL: [CitationRole; 26] = [
pub const ALL: [CitationRole; 27] = [
Self::RaisedIn,
Self::Answers,
Self::Rulings,
@ -485,6 +487,7 @@ impl CitationRole {
Self::DayThrough,
Self::Fork,
Self::Answer,
Self::Listed,
];
/// The wire spelling, equal to the referring field's name.
@ -516,6 +519,7 @@ impl CitationRole {
Self::DayThrough => "day_through",
Self::Fork => "fork",
Self::Answer => "answer",
Self::Listed => "listed",
}
}
@ -543,6 +547,7 @@ impl CitationRole {
Self::Run => K::Reply,
Self::Reads => K::Thought,
Self::Fork | Self::Answer => K::DeskAnswer,
Self::Listed => K::DeskListing,
}
}
@ -552,7 +557,7 @@ impl CitationRole {
pub fn target_kinds(self) -> Vec<PipelineKind> {
use PipelineKind as K;
match self {
Self::Answers | Self::Questions | Self::Forks | Self::Fork => vec![K::Question],
Self::Answers | Self::Questions | Self::Forks | Self::Fork | Self::Listed => vec![K::Question],
Self::Rulings => vec![K::Ruling],
Self::CutSpec => vec![K::CutSpec],
Self::CutReport => vec![K::CutReport],
@ -670,6 +675,7 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel
DeskOutcome::Counted | DeskOutcome::Refused { .. } => {}
}
}
D::DeskListing(listing) => edges.push((R::Listed, cited(K::Question, &listing.listed))),
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {}
}
edges

View file

@ -408,9 +408,9 @@ mod tests {
fn desk_answer() -> DeskRequest {
DeskRequest::Answer {
mind: slug(INSTANCE),
subject: crate::fixtures::s("user-7"),
subject: crate::fixtures::acct("user-7"),
authority: DeskAuthority::Member,
deciders: vec![crate::fixtures::s("user-7")],
deciders: vec![crate::fixtures::acct("user-7")],
request: ForkAnswerRequest {
schema_version: crate::desk_wire::RequestSchema::V0,
fork_id: "eureka-state:question:Q1".into(),
@ -425,7 +425,7 @@ mod tests {
}
fn desk_requests() -> [(DeskRequest, &'static str); 3] {
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]);
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::acct("user-7"), vec![crate::fixtures::acct("user-7")]);
[
(desk_answer(), "Answer"),
(DeskRequest::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone(), status: Some(crate::desk_wire::ListStatus::Open), cursor: crate::desk_wire::ForkCursor::new(7), limit: crate::desk_wire::ForkLimit::new(50).unwrap() }, "Forks"),
@ -579,13 +579,13 @@ mod tests {
fn a_maximal_desk_message_fits_the_frame() {
let long = |n: usize| "x".repeat(n);
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len();
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::s(&format!("{at:0>200}"))).collect();
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::acct(&format!("decider {at}"))).collect();
let mut sized = vec![];
for (request, _) in desk_requests() {
let widest = match request {
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
mind,
subject: crate::fixtures::s(&long(200)),
subject: crate::fixtures::acct("the widest subject"),
authority,
deciders: deciders.clone(),
request: ForkAnswerRequest {
@ -596,10 +596,10 @@ mod tests {
},
},
DeskRequest::Forks { mind, status, cursor, limit, .. } => {
DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit }
DeskRequest::Forks { mind, subject: crate::fixtures::acct("the widest subject"), deciders: deciders.clone(), status, cursor, limit }
}
DeskRequest::Fork { mind, .. } => {
DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) }
DeskRequest::Fork { mind, subject: crate::fixtures::acct("the widest subject"), deciders: deciders.clone(), fork_id: long(200) }
}
};
let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
@ -607,7 +607,8 @@ mod tests {
sized.push(wire(&message));
}
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}");
assert!(sized[0] > DESK_MAX_REQUEST_BYTES / 4, "the test builds a request that is actually large: {sized:?}");
// 64 account ids of 36 bytes and a 4,000-byte note: the widest answer a request can carry.
assert!(sized[0] > 4000 + 64 * 36, "the test builds a request that is actually large: {sized:?}");
let answer = ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0,

View file

@ -56,6 +56,21 @@ pub(crate) fn s(value: &str) -> Short {
value.into()
}
/// A deterministic Bifrost account id for a test's name for someone: a lowercase D-format UUID.
pub(crate) fn account(name: &str) -> Short {
// An id already in account form stands for itself.
if name.len() == 36 && name.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f' | b'-')) {
return Short(name.into());
}
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
Short(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
}
/// The same, as the stored type.
pub(crate) fn acct(name: &str) -> eureka_pipeline::AccountId {
eureka_pipeline::AccountId(account(name).0)
}
pub(crate) fn l(value: &str) -> Label {
value.into()
}

View file

@ -105,8 +105,8 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
lines.push(&idea.idea.0);
lines.push(&idea.tradeoffs.0);
}
// A desk answer is a person's words, not indexed.
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) => {}
// A desk answer is a person's words and a listing's reason is no retrieval text: neither is indexed.
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) | D::DeskListing(_) => {}
}
lines.retain(|line| !line.is_empty());
if lines.is_empty() {

View file

@ -166,6 +166,8 @@ pub enum PipelineFacts {
/// the one that counts for its fork at the read's snapshot, derived by `counted_answer` and never
/// stored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`.
DeskAnswer { fork: Short, sequence: u32, outcome: Short, counts: bool, authority: DeskAuthority },
/// The question this listing lets members read.
DeskListing { listed: Short },
}
impl PipelineFacts {
@ -306,6 +308,7 @@ impl PipelineFacts {
counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()),
authority: value.authority,
},
D::DeskListing(value) => Self::DeskListing { listed: value.listed.clone() },
}
}
}
@ -1849,7 +1852,7 @@ mod tests {
// Two desk answers on the open fork Q2: the second is a Revision of the first.
let D::Question(shown) = question("Q2", &["A", "B"], "A") else { unreachable!() };
let stamp = crate::desk::DeskStamp { answerer: s("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![s("x")] };
let stamp = crate::desk::DeskStamp { answerer: crate::fixtures::account("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![crate::fixtures::account("x")] };
let pick = |n: u32| crate::desk::DeskAnswerRequest {
fork: s(&q2),
options_hash: crate::desk::options_hash(&shown),
@ -1862,6 +1865,10 @@ mod tests {
assert!(matches!(mind.answer_desk(stamp.clone(), pick(n), now()), crate::desk::DeskAnswerOutcome::Stored { .. }));
}
// The Mind lists Q2: one `listed` edge.
committed(admit(&mut mind, vec![D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN), listed: s(&q2), reason: "Members may read why.".into() })]));
let listing = id("desk_listing", "Q2");
let (n1, f1_closed, f2_closed) = (
id("resolution", "question.Q1.n1"),
id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())),
@ -1910,8 +1917,9 @@ mod tests {
(CitationRole::Fork, &answer_1, &q2),
(CitationRole::Fork, &answer_2, &q2),
(CitationRole::Answer, &answer_2, &answer_1),
(CitationRole::Listed, &listing, &q2),
];
assert_eq!(CitationRole::ALL.len(), 26);
assert_eq!(CitationRole::ALL.len(), 27);
for role in CitationRole::ALL {
let selection =
Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() };

View file

@ -248,7 +248,7 @@ impl SelectionRow {
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![repo.identity()],
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => {
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) | D::DeskListing(_) => {
Vec::new()
}
},
@ -466,7 +466,7 @@ mod tests {
/// refused at the substrate's door.
#[test]
fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() {
let table: [(K, &[&str]); 21] = [
let table: [(K, &[&str]); 22] = [
(K::Campaign, &["repo"]),
(K::Target, &[]),
(K::Question, &[]),
@ -488,6 +488,7 @@ mod tests {
(K::Reply, &[]),
(K::Thought, &[]),
(K::DeskAnswer, &[]),
(K::DeskListing, &[]),
];
for (kind, extra) in table {
let want = [&["root", "in_force", "faculty"][..], extra].concat();
@ -503,7 +504,7 @@ mod tests {
/// twenty-one.
#[test]
fn each_kind_declares_the_roles_it_carries_and_their_targets() {
let table: [(K, &[&str]); 21] = [
let table: [(K, &[&str]); 22] = [
(K::Campaign, &[]),
(K::Target, &[]),
(K::Question, &["raised_in"]),
@ -525,13 +526,14 @@ mod tests {
(K::Reply, &["run"]),
(K::Thought, &["reads"]),
(K::DeskAnswer, &["fork", "answer"]),
(K::DeskListing, &["listed"]),
];
for (kind, roles) in table {
assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}");
}
assert_eq!(Vocabulary.target_leaves("answers"), vec![K::Question.type_id().to_string()]);
assert_eq!(Vocabulary.target_leaves("rulings"), vec![K::Ruling.type_id().to_string()]);
assert_eq!(Vocabulary.target_leaves("subject").len(), 21);
assert_eq!(Vocabulary.target_leaves("subject").len(), 22);
assert_eq!(
sorted(Vocabulary.target_leaves("claims")),
sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect())

View file

@ -21,8 +21,8 @@ use huginn_mind::desk_wire::{
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};
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_request, encode_desk_response};
use huginn_mind::eureka_pipeline::{AccountId, DeskAuthority, Short, Slug};
const RUNTIME: &str = "huginn-eureka";
/// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format
@ -42,11 +42,15 @@ fn s(text: &str) -> Short {
Short(text.into())
}
fn a(text: &str) -> AccountId {
AccountId(text.into())
}
fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequest {
DeskRequest::Forks {
mind: Slug("eureka".into()),
subject: s(READER),
deciders: vec![s(READER), s(OTHER_DECIDER)],
subject: a(READER),
deciders: vec![a(READER), a(OTHER_DECIDER)],
status,
cursor: cursor.and_then(ForkCursor::new),
limit: ForkLimit::new(limit).unwrap(),
@ -54,15 +58,15 @@ fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequ
}
fn read() -> DeskRequest {
DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(READER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() }
DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(READER), deciders: vec![a(READER), a(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)],
subject: a(NON_DECIDER),
deciders: vec![a(READER), a(OTHER_DECIDER)],
status: Some(ListStatus::Open),
cursor: None,
limit: ForkLimit::new(50).unwrap(),
@ -70,7 +74,7 @@ fn forks_as_non_decider() -> DeskRequest {
}
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() }
DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(NON_DECIDER), deciders: vec![a(READER), a(OTHER_DECIDER)], fork_id: FORK.into() }
}
fn list(open_count: u64, recorded_count: u64) -> DeskResponse {
@ -196,6 +200,24 @@ fn hex(bytes: &[u8]) -> String {
text
}
/// A request message whose payload is `request` with one field changed to something the typed request
/// cannot hold: what a client that skips the account grammar would send.
fn malformed(id: &str, request: &DeskRequest, edit: impl Fn(&mut serde_json::Value)) -> CultNetMessage {
use base64::Engine;
let mut value = serde_json::to_value(request).unwrap();
edit(&mut value);
CultNetMessage::OperationRequest {
message_id: id.into(),
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
operation: "fork".into(),
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
payload_encoding: "messagepack-base64".into(),
payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()),
source_runtime_id: None,
target_runtime_id: None,
}
}
/// Every fixture: its file stem and its bytes. A message gives `.frame.bin`, `.frame.hex` and,
/// for an operation message, `.payload.msgpack` and `.payload.hex`; a bare payload gives the last two.
fn fixtures() -> Vec<(String, Vec<u8>)> {
@ -221,6 +243,15 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
// 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());
// A decider or subject that is no account id is refused at decode, naming the field.
for (name, id, request) in [
("read-mxid-decider", "m-7", malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into())),
("read-email-subject", "m-8", malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into())),
] {
message(&format!("{name}.request"), request.clone());
let failure = decode_desk_request(&request).expect_err("the door refuses it");
message(&format!("{name}.failure.response"), encode_desk_failure(id, "fork", &failure, RUNTIME));
}
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(
@ -290,8 +321,7 @@ fn account_id(text: &str) -> bool {
}
/// Every account id the fixtures carry is a Bifrost account id, never a Matrix id, email or
/// handle. The door does not refuse other shapes yet (`Short`, any 200 bytes); that refusal is owed
/// by `cut-desk-feed-listing.r6`, and this pins the grammar it must enforce.
/// handle, and the door refuses any other shape at decode, naming the field and never the value.
#[test]
fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
for request in [forks(Some(ListStatus::Open), None, 50), read()] {
@ -302,6 +332,14 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders");
}
assert!(record(true).answers.iter().all(|answer| account_id(answer.answerer.as_deref().unwrap())), "answerer");
for (request, field, value) in [
(malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into()), "deciders", "@heimdall:gamecult.org"),
(malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into()), "subject", "meta@gamecult.org"),
] {
let failure = decode_desk_request(&request).expect_err("a request outside the grammar is refused");
assert_eq!(failure.code, "payload-not-a-request");
assert!(failure.message.contains(field) && !failure.message.contains(value), "{}", failure.message);
}
for shape in [
"@heimdall:gamecult.org",
"meta@gamecult.org",

View file

@ -15,9 +15,9 @@ Each case has up to four files:
Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The
record's answers, notes and hash are synthetic.
**The listing gate is not live.** On `main` every showable question goes to every reader. Do not
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing`
lands (see `docs/desk-feed-door.md`).
**The listing gate is live in Huginn.** A reader who is not on the call's deciders sees only forks the
Mind lists (`docs/desk-feed-door.md`); `list-empty.response` is what such a reader gets from a Mind with
nothing listed.
| case | direction | content |
|---|---|---|
@ -31,6 +31,10 @@ lands (see `docs/desk-feed-door.md`).
| `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 |
| `read-mxid-decider.request` | client to Huginn | a `Fork` whose first decider is a Matrix id, so outside the account grammar; refused at decode |
| `read-mxid-decider.failure.response` | Huginn to Bifrost | the operation failure for it: `payload-not-a-request`, naming `deciders`, never quoting the id |
| `read-email-subject.request` | client to Huginn | a `Fork` whose subject is an email address; refused at decode |
| `read-email-subject.failure.response` | Huginn to Bifrost | the operation failure for it, naming `subject` |
| `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) |

View file

@ -0,0 +1 @@
000001738aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9606771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253466d615756735a43424762334a724c6e4e31596d706c5933513649476c75646d467361575174646d46736457553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9216669656c6420466f726b2e7375626a6563743a20696e76616c69642d76616c7565

View file

@ -0,0 +1 @@
うcodeオpayload-not-a-requestァmessageル!field Fork.subject: invalid-value

View file

@ -0,0 +1 @@
000001ad87a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9fc6761524762334a72684b686b5a574e705a47567963354c5a4a44566d4d474d79596a466c4c5468684e4751744e474d7a596930355a54646d4c5446684d6d497a597a526b4e5755325a746b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e307357316c644746415a3246745a574e316248517562334a6eaf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374b16d6574614067616d6563756c742e6f7267

View file

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

View file

@ -0,0 +1 @@
000001778aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164d9646771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253566d615756735a43424762334a724c6d526c59326c6b5a584a7a577a42644f694270626e5a6862476c6b4c585a686248566caf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9256669656c6420466f726b2e64656369646572735b305d3a20696e76616c69642d76616c7565

View file

@ -0,0 +1 @@
うcodeオpayload-not-a-requestァmessageル%field Fork.deciders[0]: invalid-value

View file

@ -0,0 +1 @@
000001b687a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164da01046761524762334a72684b686b5a574e705a47567963354b325147686c6157316b595778734f6d64686257566a645778304c6d39795a396b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a8646563696465727392b6406865696d64616c6c3a67616d6563756c742e6f7267d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666

View file

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

View file

@ -5,14 +5,13 @@ Status, 2026-11-11: the list and read wire described here is on `main` (`DeskReq
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`). 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).
nullable `answerer`, and the withholding of account ids from a non-decider (below). The listing gate
is built too: a reader who is not a decider sees a fork only while the Mind lists it (Listing, below).
Both are `cut-desk-feed-raised-at` and `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. 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.
**Operator note.** After deploy, a non-decider's Desk row for a Mind is empty until that Mind lists a
fork. Say so in #minds before the feed goes live. Neither Bifrost's receiver nor the ops root-run
(`desk-door-ops-root-run`) goes live on a Mind holding private questions before this is deployed.
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/`.
@ -99,10 +98,12 @@ no `authority`, and a payload that names it is refused whole.
`DeskEndpoints.cs` with `accountId.Value.ToString("D")`, and `StartupConfigurationValidator.cs`
refuses any decider not in that form). That is the same grammar Huginn names for `RequestId`
(`uuid_text`). These ids are opaque random UUIDs. A Matrix id, an email, a display name or any other
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`. The test
handle is never one of them. Bifrost enforces the grammar, and so does Huginn's door: `subject` and
every `deciders` entry are read as `AccountId` (`eureka-pipeline`), and decode refuses any other shape as
`payload-not-a-request`, naming the field and never quoting the value (fixtures
`read-mxid-decider.request` and `read-email-subject.request`, each with its `.failure.response`). A
subject is stored as `answerer` on an answer, and the stored `desk_answer.answerer` is an `AccountId` too,
so no other shape can enter the mind. The test
`every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use
synthetic UUIDs.
@ -212,6 +213,8 @@ does not parse as RFC3339 is served as nil. One known limit: after a campaign ha
to C), C sees B's admission time, because an import quotes its source's receipt and B's receipt is B's
own (follow-up `desk-raised-at-import-chain`).
The published schemas (`schemas/cultnet/huginn.desk_response.v1.schema.json`) type `answerer` and `raised_at` as plain `string`, the existing convention for nullable keys here; a consumer validating against CultLib's own schema must treat nil as allowed for both.
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`, and Huginn's own entry-bearing
fixtures carry it (`list-open.response` a string, `read.response` nil).
@ -226,7 +229,7 @@ fixtures carry it (`list-open.response` a string, `read.response` nil).
stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer
ids.
## Listing: which forks a reader sees (Huginn's; NOT live, see the status above)
## Listing: which forks a reader sees (Huginn's)
Huginn owns audience and visibility, at its door. The card has no audience key (CultLib), and
Bifrost's feed applies no fork filter.
@ -248,10 +251,10 @@ Bifrost's feed applies no fork filter.
gate accepts this leak: no fork's text, id or count leaves the door. Ranking over the visible set
only would make `order` depend on the reader and move whenever a fork is listed, which breaks
CultLib's rule that an order never changes and breaks every held cursor.
- **The listing record** is planned kind `desk_listing`, key `<campaign>:desk_listing:<question-label>`,
- **The listing record** is kind `desk_listing` (`epiphany.pipeline.desk_listing.v2`), key `<campaign>:desk_listing:<question-label>`,
fields `campaign`, `listed` (the question's id, in the same campaign) and `reason` (one line: why
members may read it). It is
admitted on the Mind's own door, so its receipt records who listed it and when. Unlisting is a
admitted on the Mind's own Admit door only (the desk door refuses one), so its receipt records who listed it and when. Unlisting is a
`Withdrawn` resolution on it, and relisting withdraws that resolution, which reopens it. Each
flip is a document with its own receipt, so the history is `huginn history <listing id>`. No flag
is stored anywhere else.

View file

@ -1,5 +1,11 @@
{
"$defs": {
"AccountId": {
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
"maxLength": 36,
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
"type": "string"
},
"DeskAuthority": {
"enum": [
"Operator",
@ -136,7 +142,7 @@
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"

View file

@ -0,0 +1,40 @@
{
"$defs": {
"Line": {
"description": "Text of at most 1,000 UTF-8 bytes.",
"maxLength": 1000,
"type": "string"
},
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"maxLength": 200,
"type": "string"
},
"Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_listing.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.",
"properties": {
"campaign": {
"$ref": "#/$defs/Slug"
},
"listed": {
"$ref": "#/$defs/Short"
},
"reason": {
"$ref": "#/$defs/Line"
}
},
"required": [
"campaign",
"listed",
"reason"
],
"title": "PipelineDeskListing",
"type": "object"
}

View file

@ -178,7 +178,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -182,7 +182,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -37,7 +37,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -154,7 +154,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -42,7 +42,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -32,7 +32,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -104,7 +104,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -37,7 +37,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -75,7 +75,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -32,7 +32,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -37,7 +37,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
],
"type": "string"
},

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskRequest",
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `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.",
"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,\nsees every showable fork and is told the ids; any other reads only the forks the Mind lists\n(a `desk_listing` in force) and is told counts in the ids' 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",
@ -15,7 +15,7 @@
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"maxItems": 64,
"minItems": 1,
@ -28,7 +28,7 @@
"$ref": "#/$defs/ForkAnswerRequest"
},
"subject": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
}
},
"additionalProperties": false,
@ -59,7 +59,7 @@
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"maxItems": 64,
"minItems": 1,
@ -80,7 +80,7 @@
]
},
"subject": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
}
},
"additionalProperties": false,
@ -108,7 +108,7 @@
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"maxItems": 64,
"minItems": 1,
@ -121,7 +121,7 @@
"$ref": "#/$defs/Slug"
},
"subject": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
}
},
"additionalProperties": false,
@ -140,6 +140,12 @@
}
],
"$defs": {
"AccountId": {
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
"type": "string",
"maxLength": 36,
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"DeskAuthority": {
"type": "string",
"enum": [
@ -215,11 +221,6 @@
"gamecult.fork.answer_request.v0"
]
},
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"type": "string",
"maxLength": 200
},
"Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.",
"type": "string",

View file

@ -1682,7 +1682,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
]
},
"PipelineRefusal": {

View file

@ -267,6 +267,12 @@
}
],
"$defs": {
"AccountId": {
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
"type": "string",
"maxLength": 36,
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"AuthorityMap": {
"type": "object",
"properties": {
@ -1320,7 +1326,7 @@
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"
@ -1390,6 +1396,26 @@
"outcome"
]
},
"PipelineDeskListing": {
"description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.",
"type": "object",
"properties": {
"campaign": {
"$ref": "#/$defs/Slug"
},
"listed": {
"$ref": "#/$defs/Short"
},
"reason": {
"$ref": "#/$defs/Line"
}
},
"required": [
"campaign",
"listed",
"reason"
]
},
"PipelineDocument": {
"description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.",
"oneOf": [
@ -1728,6 +1754,22 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_listing"
},
"value": {
"$ref": "#/$defs/PipelineDeskListing"
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -1995,7 +2037,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
]
},
"PipelineMemory": {

View file

@ -179,6 +179,12 @@
}
],
"$defs": {
"AccountId": {
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
"type": "string",
"maxLength": 36,
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"AdmissionFacts": {
"description": "What admission recorded about a document when it landed: the receipt that\nwrote it, the `now` that receipt was taken at, who asked, and the\nreceipt's ordinal, which is the read side's order and its snapshot\nposition.",
"type": "object",
@ -430,7 +436,8 @@
"reads",
"day_through",
"fork",
"answer"
"answer",
"listed"
]
},
"ClaimOutcome": {
@ -2870,7 +2877,7 @@
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/AccountId"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"
@ -2940,6 +2947,26 @@
"outcome"
]
},
"PipelineDeskListing": {
"description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.",
"type": "object",
"properties": {
"campaign": {
"$ref": "#/$defs/Slug"
},
"listed": {
"$ref": "#/$defs/Short"
},
"reason": {
"$ref": "#/$defs/Line"
}
},
"required": [
"campaign",
"listed",
"reason"
]
},
"PipelineDocument": {
"description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.",
"oneOf": [
@ -3278,6 +3305,22 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_listing"
},
"value": {
"$ref": "#/$defs/PipelineDeskListing"
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -4251,6 +4294,31 @@
"kind",
"value"
]
},
{
"description": "The question this listing lets members read.",
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_listing"
},
"value": {
"type": "object",
"properties": {
"listed": {
"$ref": "#/$defs/Short"
}
},
"required": [
"listed"
]
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -4518,7 +4586,8 @@
"Idea",
"Reply",
"Thought",
"DeskAnswer"
"DeskAnswer",
"DeskListing"
]
},
"PipelineMemory": {

View file

@ -243,6 +243,17 @@
"title": "Epiphany Pipeline DeskAnswer v2",
"path": "epiphany.pipeline.desk_answer.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_listing.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.desk_listing.v2",
"documentType": "epiphany.pipeline.desk_listing.v2",
"title": "Epiphany Pipeline DeskListing v2",
"path": "epiphany.pipeline.desk_listing.v2.schema.json"
},
{
"schemaId": "huginn.mind_request.v1",
"kind": "wire_message",