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

This commit is contained in:
Eureka 2026-11-14 02:15:20 +00:00 • committed by Eureka
commit db9605fbf2
55 changed files with 925 additions and 243 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.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.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.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.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-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 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; Words = 4000, words_text;
/// A client's retry token: a lowercase D-format UUID. /// A client's retry token: a lowercase D-format UUID.
RequestId = 36, uuid_text; 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}`. /// A key label: `[A-Za-z0-9_-]{1,64}`.
Label = 64, label_text; Label = 64, label_text;
/// A campaign slug or key segment: label parts joined by `.`. /// A campaign slug or key segment: label parts joined by `.`.
@ -885,8 +889,13 @@ value_types! {
pub struct PipelineDeskAnswer { pub struct PipelineDeskAnswer {
campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option<Short>, note: Option<Words>, 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, 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 PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec<VerdictClaim>[64] }
pub struct PipelineFinding { pub struct PipelineFinding {
campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence, campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence,
@ -1425,6 +1434,16 @@ pipeline_kinds! {
"epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument"; "epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument";
DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer", DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer",
"epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument"; "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 /// 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`), /// The longest kind name a resolution can name as its subject (`stewardship`),
/// checked against `PipelineKind::ALL` by a test. /// 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`. /// A `.n<sequence>` part at `u32::MAX`.
const SEQUENCE_PART_MAX: usize = ".n".len() + "4294967295".len(); 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); let sequence = format!("n{}", value.sequence);
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &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) => { D::Verdict(value) => {
let report = parent_local("verdict.cut_report", &value.cut_report.0, &value.campaign.0, PipelineKind::CutReport)?; 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')?; 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)), 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"), 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)), 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") }, outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") },
}), format!("{CAMPAIGN}: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!(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] #[test]
@ -4109,7 +4135,7 @@ mod tests {
}; };
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?; 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"); 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!( assert_eq!(
variants.iter().map(tag).collect::<Vec<_>>(), variants.iter().map(tag).collect::<Vec<_>>(),
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(), PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
@ -4639,7 +4665,7 @@ mod tests {
} }
fn desk_answer_sample() -> PipelineDeskAnswer { 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 answer
} }

View file

@ -20,7 +20,7 @@ macro_rules! kinds {
kinds!( kinds!(
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship, 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 /// 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); let names = run(&[], &["schema"], None);
assert_eq!(names.code, 0); assert_eq!(names.code, 0);
let names: Vec<&str> = names.out.lines().collect(); 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[0], "campaign");
assert_eq!(names[14], "pulse"); assert_eq!(names[14], "pulse");
assert_eq!(names[15], "memory"); assert_eq!(names[15], "memory");
assert_eq!((names[16], names[17]), ("research", "idea")); 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 { for kind in PipelineKind::ALL {
assert!(names.contains(&kind.name()), "{}", kind.name()); assert!(names.contains(&kind.name()), "{}", kind.name());
} }

View file

@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use cultnet_rs::Selection; use cultnet_rs::Selection;
use crate::sources::forge::HttpForge; use crate::sources::forge::HttpForge;
use huginn_mind::desk_wire::{DeskRequest, DeskResponse}; 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::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
use huginn_mind::{ChatHits, ChatQuery, ChatWindow, ChatWindowQuery, Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery}; use huginn_mind::{ChatHits, ChatQuery, ChatWindow, ChatWindowQuery, Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
@ -109,6 +109,15 @@ impl Handled {
} }
} }
/// 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()
}
/// What the log says of the hits the mind held no document for: how many, and /// 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 first few by name (a restored mind can leave hundreds), or nothing when
/// the mind held them all. /// the mind held them all.
@ -271,10 +280,10 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
} }
match request { match request {
DeskRequest::Answer { subject, authority, deciders, 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::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(&subject, &deciders, &fork_id), DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&short_id(subject), &short_ids(&deciders), &fork_id),
} }
} }

View file

@ -20,6 +20,13 @@
//! gets `deciders` empty, `answerer` nil and the counts instead. The reads carry no `authority` (a //! gets `deciders` empty, `answerer` nil and the counts instead. The reads carry no `authority` (a
//! payload that names one is refused whole), and the door has no other way to ask who is reading. //! payload that names one is refused whole), and the door has no other way to ask who is reading.
//! //!
//! The listing gate: a reader who is not a decider sees a fork only while a `desk_listing` for it is
//! in force, and a fork it may not see reads `fork_unknown`, the same bytes as a missing one, on
//! every desk operation, a replay of a stored answer included. The door never writes a listing; it
//! arrives by the RUDP Admit door only. Unlisting is a `Withdrawn` resolution on the listing, and
//! admitting the listing again after that returns `AlreadyAdmitted` and the fork stays hidden:
//! relisting is withdrawing the withdrawal, not a second admission.
//!
//! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is //! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is
//! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is //! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is
//! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE` //! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE`

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 R1: &str = "00000000-0000-4000-8000-000000000001";
const R2: &str = "00000000-0000-4000-8000-000000000002"; const R2: &str = "00000000-0000-4000-8000-000000000002";
const R3: &str = "00000000-0000-4000-8000-000000000003"; const R3: &str = "00000000-0000-4000-8000-000000000003";
fn answer(who: &str, authority: DeskAuthority, deciders: &[&str], request: ForkAnswerRequest) -> DeskRequest { 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`. /// A list read by `who` against `deciders`.
fn forks(who: &str, deciders: &[&str], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: u8) -> DeskRequest { fn forks(who: &str, deciders: &[&str], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: u8) -> DeskRequest {
DeskRequest::Forks { DeskRequest::Forks {
mind: slug(INSTANCE), mind: slug(INSTANCE),
subject: short(who), subject: acct(who),
deciders: deciders.iter().map(|d| short(d)).collect(), deciders: deciders.iter().map(|d| acct(d)).collect(),
status, status,
cursor, cursor,
limit: ForkLimit::new(limit).unwrap(), 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 { 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) { fn page_of(response: DeskResponse) -> (ForkList, u64) {
@ -205,6 +211,13 @@ fn rig(peer_uid: u32) -> Rig {
} }
impl 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. /// 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 { fn serving<T: Send>(&mut self, client: impl FnOnce(&Path) -> T + Send) -> T {
let path = self.path.clone(); let path = self.path.clone();
@ -567,7 +580,7 @@ mod door_smoke {
claim_in_future: false, claim_in_future: false,
request_id: RequestId(R1.into()), request_id: RequestId(R1.into()),
body_sha256: Sha256Hex("0".repeat(64)), body_sha256: Sha256Hex("0".repeat(64)),
answerer: short("x"), answerer: acct("x"),
authority: DeskAuthority::Operator, authority: DeskAuthority::Operator,
outcome: DeskOutcome::Counted, outcome: DeskOutcome::Counted,
}); });
@ -601,6 +614,7 @@ mod door_smoke {
#[test] #[test]
fn deciders_ride_each_request() { fn deciders_ride_each_request() {
let mut rig = rig(my_uid()); let mut rig = rig(my_uid());
rig.list_the_fork();
let (before, hash) = (rig.state(), rig.hash.clone()); let (before, hash) = (rig.state(), rig.hash.clone());
let out = rig.serving(|path| { let out = rig.serving(|path| {
let ask = |id: &str, who: &str, deciders: &[&str], request_id: &str| { let ask = |id: &str, who: &str, deciders: &[&str], request_id: &str| {
@ -776,6 +790,7 @@ mod door_smoke {
#[test] #[test]
fn a_mind_refusal_and_a_stored_refusal_are_typed_replies() { fn a_mind_refusal_and_a_stored_refusal_are_typed_replies() {
let mut rig = rig(my_uid()); let mut rig = rig(my_uid());
rig.list_the_fork();
let (before, hash) = (rig.state(), rig.hash.clone()); let (before, hash) = (rig.state(), rig.hash.clone());
let mut foreign = answer("x", DeskAuthority::Member, &["x"], wire(&hash, R1)); let mut foreign = answer("x", DeskAuthority::Member, &["x"], wire(&hash, R1));
let DeskRequest::Answer { mind, .. } = &mut foreign else { unreachable!() }; 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!((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.stamp, format!("s{listed_at}"));
assert_eq!(list.forks[0], entry); 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_eq!(rig.state(), before, "a read stores nothing");
assert!(rig.index.seen.lock().unwrap().is_empty()); assert!(rig.index.seen.lock().unwrap().is_empty());
// An unknown fork is the answer path's refusal. // 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."; let note = "Keep it local: the author's call.";
rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer { rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer {
mind: slug(INSTANCE), mind: slug(INSTANCE),
subject: short("y"), subject: acct("y"),
authority: DeskAuthority::Member, authority: DeskAuthority::Member,
deciders: vec![short("y")], deciders: vec![acct("y")],
request: noted(&hash, R1, note), 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, 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 (list, _) = page_of(list);
let (read, _) = entry_of(read); 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 = rig.serving(|path| reply_of(path, "m-4", &fork_read("x", &["x"], FORK)));
let (seen, _) = entry_of(seen); let (seen, _) = entry_of(seen);
assert_eq!(seen.card.deciders, ["x"]); assert_eq!(seen.card.deciders, [acct("x").0]);
assert_eq!(seen.record.answers[0].answerer.as_deref(), Some("y")); assert_eq!(seen.record.answers[0].answerer.as_deref(), Some(acct("y").0.as_str()));
} }
#[test] #[test]
@ -902,7 +925,7 @@ mod door_smoke {
let refused = rig.serving(|path| failure_of(path, &bytes)); let refused = rig.serving(|path| failure_of(path, &bytes));
assert_eq!(refused.code, "payload-not-a-request", "{what}"); assert_eq!(refused.code, "payload-not-a-request", "{what}");
assert!(refused.message.contains("deciders"), "{what}: {}", refused.message); 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"); 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 { fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
DeskRequest::Answer { DeskRequest::Answer {
mind: INSTANCE.into(), mind: INSTANCE.into(),
subject: short(who), subject: acct(who),
authority: DeskAuthority::Operator, authority: DeskAuthority::Operator,
deciders: vec![short(who)], deciders: vec![acct(who)],
request: ForkAnswerRequest { request: ForkAnswerRequest {
schema_version: RequestSchema::V0, schema_version: RequestSchema::V0,
fork_id: FORK.into(), 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 { fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest {
DeskRequest::Forks { DeskRequest::Forks {
mind: INSTANCE.into(), mind: INSTANCE.into(),
subject: short(who), subject: acct(who),
deciders: deciders.iter().map(|d| short(d)).collect(), deciders: deciders.iter().map(|d| acct(d)).collect(),
status: None, status: None,
cursor: None, cursor: None,
limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(), 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.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); 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 { else {
panic!("a read of the seeded fork") panic!("a read of the seeded fork")
}; };
assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at)); assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at));
assert_eq!(daemon.documents(), seeded); assert_eq!(daemon.documents(), seeded);
// `authority` is no field of a read: the payload is refused whole and nothing is answered or stored. // `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 mut value = serde_json::to_value(&request).unwrap();
let variant = if operation == "forks" { "Forks" } else { "Fork" }; let variant = if operation == "forks" { "Forks" } else { "Fork" };
value[variant]["authority"] = serde_json::json!("Operator"); 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"))); assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1")));
let stored = daemon.documents(); let stored = daemon.documents();
assert_eq!(stored, seeded + 2); 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, .. } = 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 { else {
panic!("a read") panic!("a read")
}; };
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_deref(), answer.code)).collect(); 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.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); assert_eq!(daemon.documents(), stored);
// A stop takes the socket with it; the stored answers are the retry's memory. // 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 { 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. /// The wire spelling of a not-stored refusal's code, and its text.

View file

@ -62,12 +62,18 @@ pub(crate) enum Door {
Desk, Desk,
} }
/// Every staged `desk_answer`, whether the caller sent it or a derivation /// The door split. Every staged `desk_answer`, whether the caller sent it or a derivation produced it,
/// produced it, is refused unless it came by the desk door. /// is refused unless it came by the desk door; and a staged `desk_listing` is refused if it came by the
fn refuse_desk_answers_outside_desk(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> { /// desk door: listing is the Mind's own act, on the Admit door, with a receipt saying who.
match docs.batch.iter().find(|staged| staged.kind == PipelineKind::DeskAnswer) { fn refuse_documents_at_the_wrong_door(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> {
Some(staged) if door != Door::Desk => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }), match docs.batch.iter().find(|staged| match staged.kind {
_ => Ok(()), 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 { for envelope in envelopes {
docs.push(stage(envelope, &mind)?)?; docs.push(stage(envelope, &mind)?)?;
} }
refuse_desk_answers_outside_desk(door, &docs)?; refuse_documents_at_the_wrong_door(door, &docs)?;
// A6 // A6
let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1;
if self.is_empty() && !carries_instance { if self.is_empty() && !carries_instance {
@ -184,7 +190,7 @@ impl<S: MindStore> Mind<S> {
docs.push(stage(envelope, &mind)?)?; 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..] { for staged in &docs.batch[originals..] {
resolve(&docs, staged, &mind, external, &mut strong)?; 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. // Decided in `answer_desk` before the document exists; no other door can stage one.
D::DeskAnswer(_) => Ok(()), 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) => { D::Ruling(ruling) => {
if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator {
return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); 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::Target, O::Superseded { by }) => all(by, K::Target),
(K::Question, O::Answered { by }) => by.kind == K::Ruling, (K::Question, O::Answered { by }) => by.kind == K::Ruling,
(K::Question, O::Withdrawn { .. }) => true, (K::Question, O::Withdrawn { .. }) => true,
(K::DeskListing, O::Withdrawn { .. }) => true,
(K::Ruling, O::Superseded { by }) => all(by, K::Ruling), (K::Ruling, O::Superseded { by }) => all(by, K::Ruling),
(K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec),
(K::CutSpec, O::Withdrawn { .. }) => true, (K::CutSpec, O::Withdrawn { .. }) => true,

View file

@ -19,6 +19,7 @@
use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc}; use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc};
use eureka_pipeline::{ use eureka_pipeline::{
AccountId,
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal, DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal,
PipelineResolution, RequestId, ResolutionOutcome, Sha256Hex, Short, Slug, Words, 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. /// `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)> { 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 { 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, _ => None,
}) })
} }
@ -314,7 +315,7 @@ impl<S: MindStore> Mind<S> {
claim_in_future, claim_in_future,
request_id: request.request_id.clone(), request_id: request.request_id.clone(),
body_sha256: body_sha256.clone(), body_sha256: body_sha256.clone(),
answerer: stamp.answerer.clone(), answerer: AccountId(stamp.answerer.0.clone()),
authority: stamp.authority, authority: stamp.authority,
outcome, outcome,
}; };
@ -425,7 +426,7 @@ mod tests {
const LIST: [&str; 6] = ["x", "y", "z", "a", "op", "dec"]; const LIST: [&str; 6] = ["x", "y", "z", "a", "op", "dec"];
fn stamp_as(who: &str, authority: DeskAuthority, list: &[&str]) -> DeskStamp { 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 { fn decider(who: &str) -> DeskStamp {
@ -531,7 +532,7 @@ mod tests {
let mut mind = desk(); let mut mind = desk();
stored(ask(&mut mind, &decider("z"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); 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"))); 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] #[test]
@ -780,7 +781,7 @@ mod tests {
let record = held(&mind, &answer); let record = held(&mind, &answer);
assert_eq!(record.outcome, outcome); assert_eq!(record.outcome, outcome);
assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); 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] #[test]
@ -790,7 +791,7 @@ mod tests {
for (who, authority) in [("op", DeskAuthority::Operator), ("dec", DeskAuthority::Steward), ("x", DeskAuthority::Member)] { 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 (answer, _) = stored(ask(&mut mind, &stamp_as(who, authority, &LIST), pick(FORK, "Q1", "A", at)));
let record = held(&mind, &answer); 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 DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") };
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); 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.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 image = first.image().unwrap();
let found = Docs::at(image, image.head).find(K::DeskAnswer, &key("Q1", 1)).cloned(); let found = Docs::at(image, image.head).find(K::DeskAnswer, &key("Q1", 1)).cloned();
let Some(PipelineDocument::DeskAnswer(mut held)) = found else { panic!("stored") }; 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()); store.plant(PipelineDocument::DeskAnswer(held).prepare(&cache).unwrap());
let mut mind = opened(store, INSTANCE); 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); let before = head(&mind);
assert_eq!(replayed(ask(&mut mind, &stamp, request)), (key("Q1", 1), DeskOutcome::Counted)); assert_eq!(replayed(ask(&mut mind, &stamp, request)), (key("Q1", 1), DeskOutcome::Counted));
assert_eq!(head(&mind), before); 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)); 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())); 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!((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, &y_answer).answerer, acct("y"));
assert_eq!(held(&mind, &key("Q1", 1)).answerer, s("x")); assert_eq!(held(&mind, &key("Q1", 1)).answerer, acct("x"));
// x's later retry still returns n1; y's returns n2. // 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("x"), sent.clone())).0, key("Q1", 1));
assert_eq!(replayed(ask(&mut mind, &decider("y"), sent)).0, key("Q1", 2)); 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 chrono::{DateTime, Utc};
use eureka_pipeline::{ 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 schemars::JsonSchema;
use serde::{Deserialize, Serialize}; 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`). /// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
pub const DESK_DECIDERS_MAX: usize = 64; 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 /// An account id read from the wire: a lowercase D-format UUID, or the decoder's refusal, which names the
/// bad list is the decoder's refusal, which names the field and never quotes a subject. /// field and never quotes the value.
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> { 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; 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 { 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 { fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a list of distinct deciders") 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(); 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 { if list.len() == DESK_DECIDERS_MAX {
return Err(serde::de::Error::custom("too many deciders")); 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 { fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ schemars::json_schema!({
"type": "array", "type": "array",
"items": generator.subschema_for::<Short>(), "items": generator.subschema_for::<AccountId>(),
"minItems": 1, "minItems": 1,
"maxItems": DESK_DECIDERS_MAX, "maxItems": DESK_DECIDERS_MAX,
"uniqueItems": true "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 /// 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` /// `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 /// 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 /// 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 /// 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 /// strips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
/// reads spec. /// reads spec.
@ -289,19 +305,21 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
pub enum DeskRequest { pub enum DeskRequest {
Answer { Answer {
mind: Slug, mind: Slug,
subject: Short, #[serde(deserialize_with = "account_id")]
subject: AccountId,
authority: DeskAuthority, authority: DeskAuthority,
#[serde(deserialize_with = "deciders")] #[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")] #[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>, deciders: Vec<AccountId>,
request: ForkAnswerRequest, request: ForkAnswerRequest,
}, },
Forks { Forks {
mind: Slug, mind: Slug,
subject: Short, #[serde(deserialize_with = "account_id")]
subject: AccountId,
#[serde(deserialize_with = "deciders")] #[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")] #[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>, deciders: Vec<AccountId>,
#[serde(deserialize_with = "required")] #[serde(deserialize_with = "required")]
#[schemars(required)] #[schemars(required)]
status: Option<ListStatus>, status: Option<ListStatus>,
@ -312,10 +330,11 @@ pub enum DeskRequest {
}, },
Fork { Fork {
mind: Slug, mind: Slug,
subject: Short, #[serde(deserialize_with = "account_id")]
subject: AccountId,
#[serde(deserialize_with = "deciders")] #[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")] #[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>, deciders: Vec<AccountId>,
fork_id: String, fork_id: String,
}, },
} }
@ -337,7 +356,7 @@ impl DeskRequest {
} }
} }
pub fn deciders(&self) -> &[Short] { pub fn deciders(&self) -> &[AccountId] {
match self { match self {
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders, Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
} }
@ -530,10 +549,13 @@ impl<S: MindStore> Mind<S> {
}; };
let image = self.image()?; let image = self.image()?;
let docs = Docs::at(image, image.head); let docs = Docs::at(image, image.head);
// A request already stored replays whatever the list is now: the lookup is `answer_desk`'s, // A fork this reader may not see is unshowable here too, by the read side's own `visible`: the answer path
// asked here only so the gate does not shadow it. // must not tell a hidden fork from a missing one, nor store a refusal for it. A request already stored replays
let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none() // whatever the decider list is now (the lookup is `answer_desk`'s, asked here only so `showable` does not
&& matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders)); // shadow it), but never to a reader who can no longer see the fork: the counted answer id and `as_of` it
// would carry are the fork's current state, so that replay is `fork_unknown` too (ruling clause 3).
let replay = bound_answer(&docs, &subject, &sent.request_id).is_some();
let unshowable = matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if (!replay && !showable(question, deciders)) || !reads::visible(&docs, &sent.fork.0, deciders.contains(&subject)));
if unshowable { if unshowable {
return Ok(DeskResponse::NotStored(fork_unknown())); return Ok(DeskResponse::NotStored(fork_unknown()));
} }
@ -597,6 +619,11 @@ mod tests {
fn desk() -> Mind<MemoryStore> { fn desk() -> Mind<MemoryStore> {
let mut mind = seeded(); let mut mind = seeded();
committed(admit(&mut mind, vec![question_n(1), question_n(2)])); 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 mind
} }
@ -632,14 +659,14 @@ mod tests {
} }
fn deciders(list: &[&str]) -> Vec<Short> { 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 /// 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. /// refusal store nothing, so the head does not move.
fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) { fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) {
let before = head(mind); 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) (response, head(mind) != before)
} }
@ -779,7 +806,7 @@ mod tests {
request_id: RequestId(text("request_id").unwrap()), request_id: RequestId(text("request_id").unwrap()),
body_sha256: Sha256Hex("0".repeat(64)), 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. // 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, authority,
outcome, outcome,
}; };
@ -1286,6 +1313,54 @@ mod tests {
} }
} }
/// Ruling clause 3: a stored request replays whatever the decider list is now, but never to a reader who can no
/// longer see the fork. The replay would carry the fork's current counted answer id and head, so it is the same
/// bytes a missing fork answers with; the stored word itself comes back once the fork is listed again.
#[test]
fn a_stored_answer_does_not_replay_to_a_reader_who_can_no_longer_see_the_fork() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
// Dan is not a decider: his answer to the listed fork is a stored refusal and counts nothing.
let dan_request = wire(FORK, &hash);
let (first, wrote) = ask(&mut mind, "d", dan_request.clone());
let (refused, counted, _) = answered(first);
assert!((wrote, refused.outcome.as_str(), refused.code.is_some(), counted) == (true, "Refused", true, None), "{refused:?}");
// A decider answers, so the fork now has a counted answer a replay could name.
let x_request = wire(FORK, &hash);
let (counted_by_x, _) = ask(&mut mind, "x", x_request.clone());
assert!(answered(counted_by_x).1.is_some());
// Still listed: the replay is the stored word, with the fork's current state.
let before = head(&mind);
let (listed_replay, wrote) = ask(&mut mind, "d", dan_request.clone());
assert!(!wrote);
let (_, counted, as_of) = answered(listed_replay);
assert_eq!((counted.is_some(), as_of), (true, before));
// Unlisted: the replay is fork_unknown, byte for byte a fork that does not exist, and writes nothing.
let withdrawal = crate::fixtures::resolution_n(
eureka_pipeline::PipelineRef { kind: PipelineKind::DeskListing, id: s("eureka-state:desk_listing:Q1") },
1,
eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "unlisted".into() },
);
let withdrawal_key = eureka_pipeline::pipeline_key(&withdrawal).unwrap();
committed(admit(&mut mind, vec![withdrawal]));
let before = head(&mind);
let (hidden, wrote) = ask(&mut mind, "d", dan_request.clone());
assert!(!wrote);
let (missing, _) = ask(&mut mind, "d", ForkAnswerRequest { fork_id: "eureka-state:question:Q99".into(), ..wire(FORK, &hash) });
assert_eq!(not_stored(hidden.clone()).code, ForkRefusalCode::ForkUnknown);
assert_eq!(encode(&hidden), encode(&missing));
// A decider is told the fork whether or not it is listed.
let (decider_replay, wrote) = ask(&mut mind, "x", x_request);
assert!(!wrote && answered(decider_replay).1.is_some());
assert_eq!(head(&mind), before);
// Relisted: the stored word replays again.
let subject = eureka_pipeline::PipelineRef { kind: PipelineKind::Resolution, id: s(&withdrawal_key) };
committed(admit(&mut mind, vec![crate::fixtures::resolution(subject, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "listed again".into() })]));
let (relisted, wrote) = ask(&mut mind, "d", dan_request);
assert!(!wrote);
assert_eq!(answered(relisted).0.outcome, "Refused");
}
#[test] #[test]
fn showable_needs_two_options_text_depends_and_a_decider() { fn showable_needs_two_options_text_depends_and_a_decider() {
let D::Question(plain) = question("Q9", &["A", "B"], "A") else { unreachable!() }; let D::Question(plain) = question("Q9", &["A", "B"], "A") else { unreachable!() };
@ -1355,9 +1430,9 @@ mod tests {
let (other, committed) = ask(&mut mind, "y", request.clone()); let (other, committed) = ask(&mut mind, "y", request.clone());
let (other, _, _) = answered(other); let (other, _, _) = answered(other);
assert!(committed); 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); 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. // 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); 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"] { 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 +1547,7 @@ mod tests {
assert_eq!(shown_hash(&question).0, her_hash, "{name}: the card's hash"); assert_eq!(shown_hash(&question).0, her_hash, "{name}: the card's hash");
let mut mind = seeded(); let mut mind = seeded();
committed(admit(&mut mind, vec![D::Question(question)])); 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(); let list: Vec<&str> = card["deciders"].as_array().unwrap().iter().map(|who| who.as_str().unwrap()).collect();
if card["status"] == "closed" { if card["status"] == "closed" {
close(&mut mind, FORK, "RQ1", "no"); close(&mut mind, FORK, "RQ1", "no");
@ -1568,27 +1644,27 @@ mod tests {
let mut mind = desk(); let mut mind = desk();
let hash = hash_of(&mind, FORK); let hash = hash_of(&mind, FORK);
// The operator's own account, off the list, is no decider; a member on the list is. // 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)); 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); 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 ---- // ---- 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}; use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
fn list_of(n: usize) -> Vec<Short> { fn list_of(n: usize) -> Vec<AccountId> {
(0..n).map(|at| s(&format!("d{at}"))).collect() (0..n).map(|at| acct(&format!("d{at}"))).collect()
} }
/// Each request kind of the desk, carrying `list`. /// 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::Answer { mind: slug(mind), subject: acct("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::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: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() }, DeskRequest::Fork { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
] ]
} }
@ -1611,7 +1687,7 @@ mod tests {
// 64 deciders through to the Mind: the subject is listed and counts. // 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 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 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!(head(&mind) > before);
assert_eq!(answered(response).0.outcome, "Counted"); assert_eq!(answered(response).0.outcome, "Counted");
@ -1631,7 +1707,7 @@ mod tests {
let hash = hash_of(&mind, FORK); let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK)); let before = (head(&mind), stored_count(&mind, FORK));
let mut twice = list_of(3); let mut twice = list_of(3);
twice.push(s("d1")); twice.push(acct("d1"));
for (what, list) in [("empty", vec![]), ("duplicate", twice)] { for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
for sent in desk_requests(INSTANCE, &list, &hash) { for sent in desk_requests(INSTANCE, &list, &hash) {
let failure = through_the_door(&sent).unwrap_err(); let failure = through_the_door(&sent).unwrap_err();
@ -1667,6 +1743,6 @@ mod tests {
// The same request to the mind it names is answered. // 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") }; 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(); 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 /// 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 /// 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. /// 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 /// 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. /// 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 /// The call's `deciders` feed the showability gate; the call's `subject` decides which forks are visible and
/// is built (a decider's, with ids, or a non-decider's, with counts). It gates no fork. Notes stay whole /// which view of each entry is built. A decider sees every showable fork, with ids; any other reader sees a
/// (Bifrost strips them). /// 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 { 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) 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 /// 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 { 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) 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 image = self.image()?;
let docs = Docs::at(image, image.head); let docs = Docs::at(image, image.head);
let decider = deciders.contains(subject); 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(); shown.reverse();
let open_count = shown.iter().filter(|fork| fork.3).count() as u64; let open_count = shown.iter().filter(|fork| fork.3).count() as u64;
let recorded_count = shown.len() as u64 - open_count; 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 { let Some((order, key, question)) = ranked(&docs)?.into_iter().find(|(_, key, _)| *key == fork_id) else {
return Ok(DeskResponse::NotStored(fork_unknown())); return Ok(DeskResponse::NotStored(fork_unknown()));
}; };
if !showable(question, deciders) { if !showable(question, deciders) || !visible(&docs, key, decider) {
return Ok(DeskResponse::NotStored(fork_unknown())); return Ok(DeskResponse::NotStored(fork_unknown()));
} }
Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders, decider)?, as_of: image.head }) 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> { 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 { fn head(mind: &Mind<MemoryStore>) -> u64 {
@ -568,7 +582,7 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(), answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(), 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) { fn close(mind: &mut Mind<MemoryStore>, fork: &str, label: &str) {
@ -605,7 +619,7 @@ mod tests {
} }
fn all(mind: &Mind<MemoryStore>, n: u8) -> ForkList { 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 ---- // ---- the reads, one snapshot ----
@ -616,7 +630,7 @@ mod tests {
let stale = answer(&mut mind, "x", &LIST, &fork(1), &"0".repeat(64), None); 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") }; let DeskResponse::Answered { answer: stale, .. } = stale else { panic!("a stale answer is stored") };
assert_eq!(stale.code, Some(ForkAnswerCode::StaleFork)); 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_eq!(shown.card.options_hash, hash_of(&mind, &fork(1)));
assert_ne!(shown.card.options_hash, "0".repeat(64)); assert_ne!(shown.card.options_hash, "0".repeat(64));
assert_eq!((shown.record.answers.len(), shown.record.counted_answer_id.clone()), (1, None)); 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.outcome.as_str(), counted.answer_id.as_str()), ("Counted", "n2"));
assert_eq!(counted_answer_id.as_deref(), Some("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")); 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(); 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>"); assert_eq!(names, ["n1", "n2"], "answers in admission order, named n<seq>");
@ -636,23 +650,23 @@ mod tests {
#[test] #[test]
fn list_then_read_at_one_stamp() { fn list_then_read_at_one_stamp() {
let mut mind = desk(3); 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!(as_of, head(&mind));
assert_eq!(first.stamp, format!("s{as_of}")); assert_eq!(first.stamp, format!("s{as_of}"));
assert_eq!(first.mind, INSTANCE); assert_eq!(first.mind, INSTANCE);
assert_eq!(ids(&first), [fork(3), fork(2)]); assert_eq!(ids(&first), [fork(3), fork(2)]);
let next = first.next_cursor.expect("one more fork waits"); let next = first.next_cursor.expect("one more fork waits");
assert_eq!(next.order(), first.forks[1].card.order); 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)); assert_eq!((ids(&second), second.next_cursor), (vec![fork(1)], None));
// The read at that stamp is the entry the list carried. // 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_at, as_of);
assert_eq!(read, first.forks[1]); assert_eq!(read, first.forks[1]);
// Writing moves the stamp, and only then. // Writing moves the stamp, and only then.
let hash = hash_of(&mind, &fork(2)); let hash = hash_of(&mind, &fork(2));
answer(&mut mind, "x", &LIST, &fork(2), &hash, None); 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_eq!((moved.stamp.clone(), moved_at), (format!("s{}", head(&mind)), head(&mind)));
assert_ne!(moved.stamp, first.stamp); assert_ne!(moved.stamp, first.stamp);
assert_eq!(moved.forks[1].record.answers.len(), 1); 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"); 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)] { for (at, expected) in [(0, 3), (1, 2), (2, 1)] {
let below = ForkCursor::new(orders[at]).unwrap(); 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_eq!(rest.forks.len(), expected, "below order {}", orders[at]);
assert!(rest.forks.iter().all(|entry| entry.card.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. // 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]))); 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. // 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)); 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); assert_eq!(over.forks.len(), 4);
} }
@ -694,7 +708,7 @@ mod tests {
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] { for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] {
for size in [0, 1, 50] { 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_eq!((shown.open_count, shown.recorded_count), expected, "{status:?} {cursor:?} {size}");
assert!(shown.forks.len() <= size as usize); assert!(shown.forks.len() <= size as usize);
for entry in &shown.forks { 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)]); 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)]); assert_eq!(ids(&recorded), [fork(4), fork(1)]);
// A cursor and a status together: the cards below the cursor that have the status. // 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 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)]); assert_eq!(ids(&rest), [fork(3), fork(2)]);
} }
#[test] #[test]
fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() { fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() {
let mind = desk(3); 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.forks.len(), counts.next_cursor), (0, None));
assert_eq!((counts.open_count, counts.recorded_count), (3, 0)); assert_eq!((counts.open_count, counts.recorded_count), (3, 0));
assert_eq!((counts.stamp, as_of), (format!("s{}", head(&mind)), head(&mind))); 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"); assert_eq!(one.forks.len(), 1, "limit 1 is one entry, not none");
} }
#[test] #[test]
fn a_desk_with_no_forks_is_an_empty_page() { fn a_desk_with_no_forks_is_an_empty_page() {
let mind = seeded(); 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.open_count, empty.recorded_count, empty.forks.len(), empty.next_cursor), (0, 0, 0, None));
assert_eq!(empty.stamp, format!("s{as_of}")); assert_eq!(empty.stamp, format!("s{as_of}"));
} }
@ -757,11 +771,11 @@ mod tests {
let shown = all(&mind, 50); let shown = all(&mind, 50);
assert_eq!(ids(&shown), [fork(2), fork(1)]); 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"); 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. // 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)); 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] #[test]
@ -770,12 +784,12 @@ mod tests {
close(&mut mind, &fork(1), "R1"); close(&mut mind, &fork(1), "R1");
let ruling_key = format!("{CAMPAIGN_ROOT}:ruling: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"] { 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. // And nothing was written by looking.
let before = head(&mind); let before = head(&mind);
let _ = mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)); let _ = mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1));
let _ = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)); let _ = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50));
assert_eq!(head(&mind), before); assert_eq!(head(&mind), before);
} }
@ -790,6 +804,9 @@ mod tests {
/// and Ben answers a second time (a retraction): four stored answers, three answerers. /// and Ben answers a second time (a retraction): four stored answers, three answerers.
fn answered_desk() -> Mind<MemoryStore> { fn answered_desk() -> Mind<MemoryStore> {
let mut mind = desk(2); 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 hash = hash_of(&mind, &fork(1));
let deciders = [ANNA, BEN]; let deciders = [ANNA, BEN];
answer(&mut mind, ANNA, &deciders, &fork(1), &hash, Some("Anna's note")); 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>) { 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 (page, _) = page(mind.desk_forks(&account(subject), &list(deciders), None, None, limit(50)));
let response = mind.desk_fork(&s(subject), &list(deciders), &fork(1)); let response = mind.desk_fork(&account(subject), &list(deciders), &fork(1));
let bytes = encode_desk_response("m", "fork", &response, "r").unwrap(); 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 bytes = cultnet_rs::encode_cultnet_message_to_vec(&bytes, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap();
let (entry, _) = entry(response); let (entry, _) = entry(response);
@ -890,7 +907,7 @@ mod tests {
// ---- raised_at: the question's first admission ---- // ---- raised_at: the question's first admission ----
fn raised_at(mind: &Mind<MemoryStore>, fork_id: &str) -> Option<String> { 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] #[test]
@ -964,18 +981,246 @@ mod tests {
#[test] #[test]
fn raised_at_is_outside_options_hash() { fn raised_at_is_outside_options_hash() {
let mind = desk(1); 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() }; 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_ne!(moved.raised_at, read.card.raised_at);
assert_eq!(moved.options_hash, read.card.options_hash); assert_eq!(moved.options_hash, read.card.options_hash);
assert_eq!(read.card.options_hash, hash_of(&mind, &fork(1))); 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(_)));
}
/// Re-admitting the listing after unlisting is the same document: AlreadyAdmitted, and the fork stays hidden.
#[test]
fn admitting_the_listing_again_after_unlisting_does_not_relist() {
let mut mind = desk(1);
list_it(&mut mind, 1);
let withdrawal = unlist(&mut mind, 1, 1);
let before = head(&mind);
let again = admit(&mut mind, vec![listing_doc(1)]);
assert!(matches!(again, PipelineAdmissionOutcome::AlreadyAdmitted { .. }), "{again:?}");
assert_eq!((head(&mind), seen_by(&mind, DAN)), (before, Vec::<String>::new()));
relist(&mut mind, &withdrawal);
assert_eq!(seen_by(&mind, DAN), [fork(1)]);
}
#[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 ask = ForkAnswerRequest {
schema_version: RequestSchema::V0,
fork_id: fork(1),
options_hash: "h".into(),
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
retracted: false,
retracts: None,
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(),
};
let (mind_slug, who) = (slug(INSTANCE), acct("a"));
let requests = [
DeskRequest::Answer { mind: mind_slug.clone(), subject: who.clone(), authority: DeskAuthority::Member, deciders: vec![who.clone()], request: ask },
DeskRequest::Forks { mind: mind_slug.clone(), subject: who.clone(), deciders: vec![who.clone()], status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
DeskRequest::Fork { mind: mind_slug, subject: who.clone(), deciders: vec![who.clone()], fork_id: fork(1) },
];
for request in &requests {
let operation = request.operation();
let variant = match operation {
"answer" => "Answer",
"forks" => "Forks",
_ => "Fork",
};
let good = encode_desk_request_value(operation, &serde_json::to_value(request).unwrap());
decode_desk_request(&good).unwrap_or_else(|failure| panic!("{operation}: {failure:?}"));
let bad_ids = ["@x:server", "meta@gamecult.org", "someone", "7", &who.0.to_uppercase(), &format!("{{{}}}", who.0), ""];
for bad in bad_ids {
for field in ["subject", "deciders"] {
let mut value = serde_json::to_value(request).unwrap();
value[variant][field] = if field == "subject" { bad.into() } else { json!([bad]) };
let failure = decode_desk_request(&encode_desk_request_value(operation, &value)).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{operation} {field}: {bad:?}");
assert!(failure.message.contains(field), "{operation}: {}", failure.message);
assert!(bad.is_empty() || !failure.message.contains(bad), "the refusal quotes {bad:?}: {}", failure.message);
}
}
}
}
fn encode_desk_request_value(operation: &str, value: &Value) -> cultnet_rs::CultNetMessage {
use base64::Engine;
cultnet_rs::CultNetMessage::OperationRequest {
message_id: "m".into(),
service_id: DESK_SERVICE_ID.into(),
operation: operation.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 ---- // ---- the card refuses a count that does not match ----
fn card_with(deciders: usize, decider_count: u64) -> Vec<u8> { fn card_with(deciders: usize, decider_count: u64) -> Vec<u8> {
let mind = desk(1); 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(); 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() rmp_serde::to_vec_named(&ForkCard { deciders: ids, decider_count, ..read.card }).unwrap()
} }
@ -1015,7 +1260,7 @@ mod tests {
held.raised_in = Some(PipelineRef { kind: PipelineKind::Ruling, id: s("eureka-state:ruling:R0") }); 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![D::Ruling(ruling("R0"))]));
committed(admit(&mut mind, vec![shown])); 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; 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?")); 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(); let options: Vec<_> = card.options.iter().map(|option| (option.id.as_str(), option.text.as_str())).collect();
@ -1024,7 +1269,7 @@ mod tests {
assert_eq!(card.depends, ["eureka-state:question:other", "a prose line"]); 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.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.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.options_hash, hash_of(&mind, &fork(1)));
assert_eq!(card.order, 0, "the only question is rank 0"); assert_eq!(card.order, 0, "the only question is rank 0");
assert_eq!(read.record.fork_id, fork(1)); assert_eq!(read.record.fork_id, fork(1));
@ -1035,9 +1280,9 @@ mod tests {
fn a_ruling_closes_the_card_and_names_itself() { fn a_ruling_closes_the_card_and_names_itself() {
let mut mind = desk(2); let mut mind = desk(2);
close(&mut mind, &fork(1), "R1"); 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"))); 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)); assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None));
} }
@ -1047,7 +1292,7 @@ mod tests {
let hash = hash_of(&mind, &fork(1)); let hash = hash_of(&mind, &fork(1));
answer(&mut mind, "x", &LIST, &fork(1), &hash, None); answer(&mut mind, "x", &LIST, &fork(1), &hash, None);
answer(&mut mind, "y", &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")); 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(); 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"))]); assert_eq!(outcomes, [("Counted", None), ("Revision", Some("n1"))]);
@ -1062,9 +1307,9 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(), answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(), 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 { .. })); 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")); assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
} }
@ -1236,7 +1481,7 @@ mod tests {
let file = transport(); let file = transport();
let flat = |message: &Value| { let flat = |message: &Value| {
json!({ "Forks": { 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"] "status": message["status"], "cursor": message["cursor"], "limit": message["limit"]
}}) }})
}; };
@ -1261,7 +1506,7 @@ mod tests {
} }
fn forks_json(status: Value, cursor: Value, limit: Value) -> Value { 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] #[test]
@ -1308,7 +1553,7 @@ mod tests {
#[test] #[test]
fn a_read_that_names_an_authority_is_not_a_request() { fn a_read_that_names_an_authority_is_not_a_request() {
let mut forks = forks_json(Value::Null, Value::Null, json!(5)); 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()); 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] { for authority in [json!("Operator"), json!("Member"), Value::Null] {
forks["Forks"]["authority"] = authority.clone(); forks["Forks"]["authority"] = authority.clone();
@ -1317,7 +1562,7 @@ mod tests {
assert!(serde_json::from_value::<DeskRequest>(fork.clone()).is_err(), "Fork with {authority}"); assert!(serde_json::from_value::<DeskRequest>(fork.clone()).is_err(), "Fork with {authority}");
} }
// And through the envelope the door reads. // 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(); let message = encode_desk_request("m-1", &typed, None).unwrap();
assert_eq!(decode_desk_request(&message).unwrap().1, typed); assert_eq!(decode_desk_request(&message).unwrap().1, typed);
} }
@ -1355,7 +1600,7 @@ mod tests {
fn pages(mind: &Mind<MemoryStore>, size: u8) -> Vec<(DeskResponse, ForkList)> { fn pages(mind: &Mind<MemoryStore>, size: u8) -> Vec<(DeskResponse, ForkList)> {
let (mut cursor, mut out) = (None, Vec::new()); let (mut cursor, mut out) = (None, Vec::new());
loop { 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()); let (page, _) = page(response.clone());
cursor = page.next_cursor; cursor = page.next_cursor;
out.push((response, page)); out.push((response, page));
@ -1406,7 +1651,7 @@ mod tests {
} }
mind 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. // 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"); 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"); assert!(fits < 50, "the desk outgrows a page");
@ -1417,18 +1662,18 @@ mod tests {
if first_page(&build(fits, mid)).forks.len() == fits as usize { low = mid } else { high = mid } 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 (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)); 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 }); 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. // 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}"); 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"); 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. // 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!(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)); 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)]); assert_eq!(ids(&rest), [fork(1)]);
} }
@ -1437,7 +1682,7 @@ mod tests {
let hash = hash_of(mind, &fork(n)); let hash = hash_of(mind, &fork(n));
let note = "a long reason, ".repeat(60); let note = "a long reason, ".repeat(60);
let mut added = 0; 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") }; let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(&note)) else { panic!("the note was not stored") };
added += 1; added += 1;
assert!(added < 200, "the record does not grow"); assert!(added < 200, "the record does not grow");
@ -1457,18 +1702,18 @@ mod tests {
let mut mind = desk(3); let mut mind = desk(3);
grown_past(&mut mind, 2, DESK_PAGE_BUDGET); grown_past(&mut mind, 2, DESK_PAGE_BUDGET);
// The newest fork pages alone: the oversize one would bust the page beside it. // 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!(ids(&first), [fork(3)]);
assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2)); 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:?}") }; let DeskResponse::NotStored(refusal) = response else { panic!("{response:?}") };
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge); assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
let order = 1; let order = 1;
assert_eq!(refusal.error, fork_too_large(&fork(2), order).error); assert_eq!(refusal.error, fork_too_large(&fork(2), order).error);
assert!(refusal.error.contains(&fork(2)) && refusal.error.contains("order 1"), "{refusal:?}"); 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. // 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); assert_eq!(entry(mind.desk_fork(&account(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))); 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)); assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
} }
@ -1499,13 +1744,13 @@ mod tests {
fn a_rank_is_stable_when_more_questions_are_admitted() { fn a_rank_is_stable_when_more_questions_are_admitted() {
let mut mind = seeded(); let mut mind = seeded();
committed(admit(&mut mind, (1..=5).map(question_n).collect())); 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))); assert_eq!((orders(&first), first.next_cursor.map(ForkCursor::order)), (vec![4, 3], Some(3)));
let held = first.next_cursor; 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(6)]));
committed(admit(&mut mind, vec![question_n(0)])); 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"); 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); let everything = all(&mind, 50);
assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]); assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]);
@ -1534,9 +1779,9 @@ mod tests {
admit_unshowable(&mut mind, "Q2"); admit_unshowable(&mut mind, "Q2");
committed(admit(&mut mind, vec![question_n(3)])); committed(admit(&mut mind, vec![question_n(3)]));
for who in [&LIST[..], &["x"][..], &["z"][..]] { 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!((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)])); committed(admit(&mut mind, vec![question_n(4)]));
assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]); assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]);
@ -1549,11 +1794,11 @@ mod tests {
let mut mind = desk(2); let mut mind = desk(2);
for n in [1, 2] { for n in [1, 2] {
grown_past(&mut mind, n, DESK_PAGE_BUDGET * 55 / 100); 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))); 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)); assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
} }
@ -1584,9 +1829,9 @@ mod tests {
assert_eq!(after.len(), 70); assert_eq!(after.len(), 70);
assert_eq!(after.iter().map(|(_, order)| *order).collect::<std::collections::BTreeSet<_>>(), (0..70).collect()); 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. // 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))); 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)); assert_eq!((orders(&last), last.next_cursor), (vec![0], None));
} }
@ -1670,7 +1915,7 @@ mod tests {
let mut mind = desk(3); let mut mind = desk(3);
close(&mut mind, &fork(1), "R9"); close(&mut mind, &fork(1), "R9");
close(&mut mind, &fork(2), "R2"); 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(1).as_deref(), Some("eureka-state:ruling:R9"));
assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2")); assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2"));
assert_eq!(named(3), None); assert_eq!(named(3), None);
@ -1721,6 +1966,9 @@ mod tests {
} }
for (order, entry) in &held { for (order, entry) in &held {
let key = fork(*order as u32); 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 answers = entry["record"]["answers"].as_array().unwrap();
let hash = hash_of(&mind, &key); let hash = hash_of(&mind, &key);
if !answers.is_empty() { if !answers.is_empty() {
@ -1741,7 +1989,7 @@ mod tests {
answered_at: "2026-09-16T11:00:00Z".into(), answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id(), 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 { } else {
answer(&mut mind, "y", &LIST, &key, &hash, None); answer(&mut mind, "y", &LIST, &key, &hash, None);
} }
@ -1753,7 +2001,7 @@ mod tests {
let (request, want) = (&step["request"], &step["response"]); let (request, want) = (&step["request"], &step["response"]);
let status: Option<ListStatus> = serde_json::from_value(request["status"].clone()).unwrap(); 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 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"]; 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!((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}"); assert_eq!(serde_json::to_value(got.next_cursor).unwrap(), want["next_cursor"], "{name}");
@ -1802,7 +2050,7 @@ mod tests {
// Every third fork is closed; the batch's ranks follow its keys, so the closed ranks are read, not computed. // 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(); let mut closed = std::collections::BTreeSet::new();
for n in (1..=24).step_by(3) { 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}")); close(&mut mind, &fork(n), &format!("R{n}"));
} }
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
@ -1815,7 +2063,7 @@ mod tests {
for size in 1..=24_u8 { for size in 1..=24_u8 {
let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0); let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0);
loop { 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; pages += 1;
assert!(page.forks.len() <= size as usize && pages < 100); assert!(page.forks.len() <= size as usize && pages < 100);
walked.extend(orders(&page)); walked.extend(orders(&page));

View file

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

View file

@ -408,9 +408,9 @@ mod tests {
fn desk_answer() -> DeskRequest { fn desk_answer() -> DeskRequest {
DeskRequest::Answer { DeskRequest::Answer {
mind: slug(INSTANCE), mind: slug(INSTANCE),
subject: crate::fixtures::s("user-7"), subject: crate::fixtures::acct("user-7"),
authority: DeskAuthority::Member, authority: DeskAuthority::Member,
deciders: vec![crate::fixtures::s("user-7")], deciders: vec![crate::fixtures::acct("user-7")],
request: ForkAnswerRequest { request: ForkAnswerRequest {
schema_version: crate::desk_wire::RequestSchema::V0, schema_version: crate::desk_wire::RequestSchema::V0,
fork_id: "eureka-state:question:Q1".into(), fork_id: "eureka-state:question:Q1".into(),
@ -425,7 +425,7 @@ mod tests {
} }
fn desk_requests() -> [(DeskRequest, &'static str); 3] { 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"), (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"), (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() { fn a_maximal_desk_message_fits_the_frame() {
let long = |n: usize| "x".repeat(n); let long = |n: usize| "x".repeat(n);
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len(); 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![]; let mut sized = vec![];
for (request, _) in desk_requests() { for (request, _) in desk_requests() {
let widest = match request { let widest = match request {
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer { DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
mind, mind,
subject: crate::fixtures::s(&long(200)), subject: crate::fixtures::acct("the widest subject"),
authority, authority,
deciders: deciders.clone(), deciders: deciders.clone(),
request: ForkAnswerRequest { request: ForkAnswerRequest {
@ -596,10 +596,10 @@ mod tests {
}, },
}, },
DeskRequest::Forks { mind, status, cursor, limit, .. } => { 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, .. } => {
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(); let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
@ -607,7 +607,8 @@ mod tests {
sized.push(wire(&message)); sized.push(wire(&message));
} }
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}"); 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 { let answer = ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0, schema_version: crate::desk_wire::AnswerSchema::V0,

View file

@ -56,6 +56,21 @@ pub(crate) fn s(value: &str) -> Short {
value.into() 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 { pub(crate) fn l(value: &str) -> Label {
value.into() value.into()
} }

View file

@ -105,8 +105,8 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
lines.push(&idea.idea.0); lines.push(&idea.idea.0);
lines.push(&idea.tradeoffs.0); lines.push(&idea.tradeoffs.0);
} }
// A desk answer is a person's words, not indexed. // 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::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) | D::DeskListing(_) => {}
} }
lines.retain(|line| !line.is_empty()); lines.retain(|line| !line.is_empty());
if lines.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 /// 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`. /// stored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`.
DeskAnswer { fork: Short, sequence: u32, outcome: Short, counts: bool, authority: DeskAuthority }, DeskAnswer { fork: Short, sequence: u32, outcome: Short, counts: bool, authority: DeskAuthority },
/// The question this listing lets members read.
DeskListing { listed: Short },
} }
impl PipelineFacts { impl PipelineFacts {
@ -306,6 +308,7 @@ impl PipelineFacts {
counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()), counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()),
authority: value.authority, 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. // 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 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 { let pick = |n: u32| crate::desk::DeskAnswerRequest {
fork: s(&q2), fork: s(&q2),
options_hash: crate::desk::options_hash(&shown), 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 { .. })); 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) = ( let (n1, f1_closed, f2_closed) = (
id("resolution", "question.Q1.n1"), id("resolution", "question.Q1.n1"),
id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())), id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())),
@ -1910,8 +1917,9 @@ mod tests {
(CitationRole::Fork, &answer_1, &q2), (CitationRole::Fork, &answer_1, &q2),
(CitationRole::Fork, &answer_2, &q2), (CitationRole::Fork, &answer_2, &q2),
(CitationRole::Answer, &answer_2, &answer_1), (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 { for role in CitationRole::ALL {
let selection = let selection =
Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() }; 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::Stewardship(stewardship) => vec![stewardship.repo.identity()],
D::HandOff(hand_off) => vec![hand_off.repo.identity()], D::HandOff(hand_off) => vec![hand_off.repo.identity()],
D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![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() Vec::new()
} }
}, },
@ -466,7 +466,7 @@ mod tests {
/// refused at the substrate's door. /// refused at the substrate's door.
#[test] #[test]
fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() { 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::Campaign, &["repo"]),
(K::Target, &[]), (K::Target, &[]),
(K::Question, &[]), (K::Question, &[]),
@ -488,6 +488,7 @@ mod tests {
(K::Reply, &[]), (K::Reply, &[]),
(K::Thought, &[]), (K::Thought, &[]),
(K::DeskAnswer, &[]), (K::DeskAnswer, &[]),
(K::DeskListing, &[]),
]; ];
for (kind, extra) in table { for (kind, extra) in table {
let want = [&["root", "in_force", "faculty"][..], extra].concat(); let want = [&["root", "in_force", "faculty"][..], extra].concat();
@ -503,7 +504,7 @@ mod tests {
/// twenty-one. /// twenty-one.
#[test] #[test]
fn each_kind_declares_the_roles_it_carries_and_their_targets() { fn each_kind_declares_the_roles_it_carries_and_their_targets() {
let table: [(K, &[&str]); 21] = [ let table: [(K, &[&str]); 22] = [
(K::Campaign, &[]), (K::Campaign, &[]),
(K::Target, &[]), (K::Target, &[]),
(K::Question, &["raised_in"]), (K::Question, &["raised_in"]),
@ -525,13 +526,14 @@ mod tests {
(K::Reply, &["run"]), (K::Reply, &["run"]),
(K::Thought, &["reads"]), (K::Thought, &["reads"]),
(K::DeskAnswer, &["fork", "answer"]), (K::DeskAnswer, &["fork", "answer"]),
(K::DeskListing, &["listed"]),
]; ];
for (kind, roles) in table { for (kind, roles) in table {
assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}"); 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("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("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!( assert_eq!(
sorted(Vocabulary.target_leaves("claims")), sorted(Vocabulary.target_leaves("claims")),
sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect()) 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, fork_too_large_read, AnswerSchema, CardSchema, CardStatus, DeskRequest, DeskResponse, EntrySchema, ForkCard, ForkCursor, ForkDeskAnswer, ForkEntry, ForkLimit,
ForkList, ForkOption, ForkRecord, ForkRefusal, ForkRefusalCode, ForkRef, ListSchema, ListStatus, RecordSchema, 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::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_request, encode_desk_response};
use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug}; use huginn_mind::eureka_pipeline::{AccountId, DeskAuthority, Short, Slug};
const RUNTIME: &str = "huginn-eureka"; const RUNTIME: &str = "huginn-eureka";
/// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format /// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format
@ -42,11 +42,15 @@ fn s(text: &str) -> Short {
Short(text.into()) Short(text.into())
} }
fn a(text: &str) -> AccountId {
AccountId(text.into())
}
fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequest { fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequest {
DeskRequest::Forks { DeskRequest::Forks {
mind: Slug("eureka".into()), mind: Slug("eureka".into()),
subject: s(READER), subject: a(READER),
deciders: vec![s(READER), s(OTHER_DECIDER)], deciders: vec![a(READER), a(OTHER_DECIDER)],
status, status,
cursor: cursor.and_then(ForkCursor::new), cursor: cursor.and_then(ForkCursor::new),
limit: ForkLimit::new(limit).unwrap(), limit: ForkLimit::new(limit).unwrap(),
@ -54,15 +58,15 @@ fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequ
} }
fn read() -> DeskRequest { 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. /// The same reads from a subject the call's deciders do not name: a non-decider.
fn forks_as_non_decider() -> DeskRequest { fn forks_as_non_decider() -> DeskRequest {
DeskRequest::Forks { DeskRequest::Forks {
mind: Slug("eureka".into()), mind: Slug("eureka".into()),
subject: s(NON_DECIDER), subject: a(NON_DECIDER),
deciders: vec![s(READER), s(OTHER_DECIDER)], deciders: vec![a(READER), a(OTHER_DECIDER)],
status: Some(ListStatus::Open), status: Some(ListStatus::Open),
cursor: None, cursor: None,
limit: ForkLimit::new(50).unwrap(), limit: ForkLimit::new(50).unwrap(),
@ -70,7 +74,7 @@ fn forks_as_non_decider() -> DeskRequest {
} }
fn read_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 { fn list(open_count: u64, recorded_count: u64) -> DeskResponse {
@ -196,6 +200,24 @@ fn hex(bytes: &[u8]) -> String {
text 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, /// 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. /// for an operation message, `.payload.msgpack` and `.payload.hex`; a bare payload gives the last two.
fn fixtures() -> Vec<(String, Vec<u8>)> { 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. // 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.response", encode_desk_response("m-4", "fork", &DeskResponse::Fork { entry: entry(true, None), as_of: 10190 }, RUNTIME).unwrap());
message("read-non-decider.response", encode_desk_response("m-6", "fork", &DeskResponse::Fork { entry: entry(false, None), as_of: 10190 }, RUNTIME).unwrap()); message("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-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("list-counts.response", encode_desk_response("m-3", "forks", &list(3, 5), RUNTIME).unwrap());
message( 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 /// 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 /// handle, and the door refuses any other shape at decode, naming the field and never the value.
/// by `cut-desk-feed-listing.r6`, and this pins the grammar it must enforce.
#[test] #[test]
fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
for request in [forks(Some(ListStatus::Open), None, 50), read()] { 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!(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"); 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 [ for shape in [
"@heimdall:gamecult.org", "@heimdall:gamecult.org",
"meta@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 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. 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 **The listing gate is live in Huginn.** A reader who is not on the call's deciders sees only forks the
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing` Mind lists (`docs/desk-feed-door.md`); `list-empty.response` is what such a reader gets from a Mind with
lands (see `docs/desk-feed-door.md`). nothing listed.
| case | direction | content | | 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 | | `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.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-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-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: []` | | `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) | | `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 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 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 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 nullable `answerer`, and the withholding of account ids from a non-decider (below). The listing gate
planned and not built: the listing gate that decides which forks a reader sees (cut spec is built too: a reader who is not a decider sees a fork only while the Mind lists it (Listing, below).
`cut-desk-feed-listing` in the `eureka-substrate` campaign). 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 **Operator note.** After deploy, a non-decider's Desk row for a Mind is empty until that Mind lists a
reader, whatever the call's `deciders` say. The default of not listed, described under Listing below, fork. Say so in #minds before the feed goes live. Neither Bifrost's receiver nor the ops root-run
holds only once `cut-desk-feed-listing` lands. Until then, neither Bifrost's receiver nor the ops (`desk-door-ops-root-run`) goes live on a Mind holding private questions before this is deployed.
root-run (`desk-door-ops-root-run`) may go live on a Mind that holds private questions.
This page is the wire contract for the consumer, Bifrost's `UnixSocketDeskDoor` (Bifrost PR #67, 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/`. `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` `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` 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 (`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 handle is never one of them. Bifrost enforces the grammar, and so does Huginn's door: `subject` and
fields as `Short` (any text up to 200 bytes) and would accept `@x:server` or an email. A subject every `deciders` entry are read as `AccountId` (`eureka-pipeline`), and decode refuses any other shape as
is stored as `answerer` on an answer, so a wrong value would enter the mind. Refusing every other `payload-not-a-request`, naming the field and never quoting the value (fixtures
shape at decode is owed by `cut-desk-feed-listing`. The test `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 `every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use
synthetic UUIDs. 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 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`). 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` 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 (`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). 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 stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer
ids. 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 Huginn owns audience and visibility, at its door. The card has no audience key (CultLib), and
Bifrost's feed applies no fork filter. 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 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 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. 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 fields `campaign`, `listed` (the question's id, in the same campaign) and `reason` (one line: why
members may read it). It is 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 `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 flip is a document with its own receipt, so the history is `huginn history <listing id>`. No flag
is stored anywhere else. is stored anywhere else.

View file

@ -1,5 +1,11 @@
{ {
"$defs": { "$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": { "DeskAuthority": {
"enum": [ "enum": [
"Operator", "Operator",
@ -136,7 +142,7 @@
"$ref": "#/$defs/Short" "$ref": "#/$defs/Short"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"authority": { "authority": {
"$ref": "#/$defs/DeskAuthority" "$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", "Idea",
"Reply", "Reply",
"Thought", "Thought",
"DeskAnswer" "DeskAnswer",
"DeskListing"
], ],
"type": "string" "type": "string"
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskRequest", "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": [ "oneOf": [
{ {
"type": "object", "type": "object",
@ -15,7 +15,7 @@
"deciders": { "deciders": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"maxItems": 64, "maxItems": 64,
"minItems": 1, "minItems": 1,
@ -28,7 +28,7 @@
"$ref": "#/$defs/ForkAnswerRequest" "$ref": "#/$defs/ForkAnswerRequest"
}, },
"subject": { "subject": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -59,7 +59,7 @@
"deciders": { "deciders": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"maxItems": 64, "maxItems": 64,
"minItems": 1, "minItems": 1,
@ -80,7 +80,7 @@
] ]
}, },
"subject": { "subject": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -108,7 +108,7 @@
"deciders": { "deciders": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"maxItems": 64, "maxItems": 64,
"minItems": 1, "minItems": 1,
@ -121,7 +121,7 @@
"$ref": "#/$defs/Slug" "$ref": "#/$defs/Slug"
}, },
"subject": { "subject": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -140,6 +140,12 @@
} }
], ],
"$defs": { "$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": { "DeskAuthority": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -215,11 +221,6 @@
"gamecult.fork.answer_request.v0" "gamecult.fork.answer_request.v0"
] ]
}, },
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"type": "string",
"maxLength": 200
},
"Slug": { "Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.", "description": "A campaign slug or key segment: label parts joined by `.`.",
"type": "string", "type": "string",

View file

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

View file

@ -267,6 +267,12 @@
} }
], ],
"$defs": { "$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": { "AuthorityMap": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1320,7 +1326,7 @@
"$ref": "#/$defs/Short" "$ref": "#/$defs/Short"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"authority": { "authority": {
"$ref": "#/$defs/DeskAuthority" "$ref": "#/$defs/DeskAuthority"
@ -1390,6 +1396,26 @@
"outcome" "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": { "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.", "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": [ "oneOf": [
@ -1728,6 +1754,22 @@
"kind", "kind",
"value" "value"
] ]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_listing"
},
"value": {
"$ref": "#/$defs/PipelineDeskListing"
}
},
"required": [
"kind",
"value"
]
} }
] ]
}, },
@ -1995,7 +2037,8 @@
"Idea", "Idea",
"Reply", "Reply",
"Thought", "Thought",
"DeskAnswer" "DeskAnswer",
"DeskListing"
] ]
}, },
"PipelineMemory": { "PipelineMemory": {

View file

@ -179,6 +179,12 @@
} }
], ],
"$defs": { "$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": { "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.", "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", "type": "object",
@ -430,7 +436,8 @@
"reads", "reads",
"day_through", "day_through",
"fork", "fork",
"answer" "answer",
"listed"
] ]
}, },
"ClaimOutcome": { "ClaimOutcome": {
@ -2870,7 +2877,7 @@
"$ref": "#/$defs/Short" "$ref": "#/$defs/Short"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/Short" "$ref": "#/$defs/AccountId"
}, },
"authority": { "authority": {
"$ref": "#/$defs/DeskAuthority" "$ref": "#/$defs/DeskAuthority"
@ -2940,6 +2947,26 @@
"outcome" "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": { "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.", "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": [ "oneOf": [
@ -3278,6 +3305,22 @@
"kind", "kind",
"value" "value"
] ]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_listing"
},
"value": {
"$ref": "#/$defs/PipelineDeskListing"
}
},
"required": [
"kind",
"value"
]
} }
] ]
}, },
@ -4251,6 +4294,31 @@
"kind", "kind",
"value" "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", "Idea",
"Reply", "Reply",
"Thought", "Thought",
"DeskAnswer" "DeskAnswer",
"DeskListing"
] ]
}, },
"PipelineMemory": { "PipelineMemory": {

View file

@ -243,6 +243,17 @@
"title": "Epiphany Pipeline DeskAnswer v2", "title": "Epiphany Pipeline DeskAnswer v2",
"path": "epiphany.pipeline.desk_answer.v2.schema.json" "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", "schemaId": "huginn.mind_request.v1",
"kind": "wire_message", "kind": "wire_message",