Desk door: the listing gate and the account-id grammar at decode
A non-decider reads a fork only while the Mind's desk_listing for it is in force; a decider sees every showable fork. Counts, pages and cursors are over the visible set; a hidden fork reads as a missing one (same bytes). New kind desk_listing (epiphany.pipeline.desk_listing.v2, key <campaign>:desk_listing:<question-label>; unlist = Withdrawn resolution, relist = withdraw that), new citation role Listed (ALL is 27), admitted on the Admit door only (the desk door refuses one). subject and every deciders entry are AccountId (lowercase D-format UUID) at decode, refused as payload-not-a-request naming the field and never the value; the stored desk_answer.answerer is an AccountId. The longest kind name is now desk_listing, so the resolution local bound is 112. Tests move to account ids (fixtures::account); refused-request fixtures and their failure frames; all published schemas, the golden envelopes and the catalog index regenerated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7620875596
commit
f3d0280535
54 changed files with 789 additions and 239 deletions
|
|
@ -32,7 +32,8 @@ sample.research eureka-state:research:p1.scope epiphany.pipeline.research.v2 Epi
|
|||
sample.idea eureka-state:idea:p1.i1 epiphany.pipeline.idea.v2 EpiphanyPipelineIdeaDocument 9189a863616d706169676eac657572656b612d7374617465a470617373a27031a56c6162656ca26931a57469746c65b24f6e65206e6f74652070657220636c61696da469646561d9204b65657020612070617373206173206d616e7920736d616c6c206e6f7465732ea974726164656f666673b74d6f726520646f63756d656e747320746f206c6973742ea872657374735f6f6e9182a46b696e64a85265736561726368a26964be657572656b612d73746174653a72657365617263683a70312e73636f7065a6746f776172649182a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131ab70726f706f7365645f6f6eaa323032362d30392d3135
|
||||
sample.reply yggdrasil:reply:mind-2026-10-09-1-r1 epiphany.pipeline.reply.v2 EpiphanyPipelineReplyDocument 9185a8696e7374616e6365a979676764726173696ca56c6162656cb46d696e642d323032362d31302d30392d312d7231a372756e82a46b696e64a352756ea26964bf79676764726173696c3a72756e3a6d696e642d323032362d31302d30392d31a7616e73776572739182a46b696e64a8466f6c6c6f775570a26964d923657572656b612d73746174653a666f6c6c6f775f75703a61736b2d7468652d73656c66a474657874d93054686520736861646572207061737320697320646f6e653b2074686520736f756e647320617265204c6962627927732e
|
||||
sample.thought yggdrasil:thought:t1793000000 epiphany.pipeline.thought.v2 EpiphanyPipelineThoughtDocument 91de0014a8696e7374616e6365a979676764726173696ca56c6162656cab7431373933303030303030a474657874d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742ea572656164739182a46b696e64a64d656d6f7279a26964d92879676764726173696c3a6d656d6f72793a323032362d31302d3331743034313230302d74302d6d30af706572736f6e615f72656365697074b06d696e642d636f6d6d69742d30613162a86964656e74697479be457572656b612c207368652f6865722c20706c61696e2d73706f6b656e2ea7636f6e74657874d92056616c7565733a20636f686572656e6365206f7665722076656c6f636974792ea87374696d756c7573bd4c696262792061736b65642061626f75742074686520736f756e64732eaf70726f6a6563746f725f6d6f64656cb070726f6a6563746f723a6c6174657374aa70726f6a656374696f6ed92949206b656570207475726e696e67206f7665722077686f20686f6c64732074686520736f756e64732eaa626173655f6d6f64656cab626173653a6c6174657374b474656d70657261747572655f7065726d696c6c65cd03e8ae6d696e5f705f7065726d696c6c6532aa6d61785f746f6b656e73cca0aa63616e6469646174657392d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742eaf41207365636f6e642064726966742eb06e6561726573745f7065726d696c6c6592cd0136cd0366a663686f73656e00ac70726f6a6563746f725f6d73cd1068ac636f6e74696e7565725f6d73cd238cae70726f6d70745f76657273696f6ebd67686f73746c696768743d633433656235343b20776f7264733d313830
|
||||
sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572a6757365722d37a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31
|
||||
sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31
|
||||
sample.desk_listing eureka-state:desk_listing:Q1 epiphany.pipeline.desk_listing.v2 EpiphanyPipelineDeskListingDocument 9183a863616d706169676eac657572656b612d7374617465a66c6973746564b8657572656b612d73746174653a7175657374696f6e3a5131a6726561736f6ed9324d656d62657273206d617920726561642077687920746865204d696e64206c6973747320697473206f776e20666f726b732e
|
||||
resolution.superseded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581aa5375706572736564656481a262799282a46b696e64a652756c696e67a26964b6657572656b612d73746174653a72756c696e673a523882a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
|
||||
resolution.fixed-by-none eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a26279c0a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
|
||||
resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a2627982a46b696e64a94375745265706f7274a26964d921657572656b612d73746174653a6375745f7265706f72743a6375742d33612e6831a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
|
||||
|
|
|
|||
|
|
@ -316,6 +316,10 @@ bounded_text! {
|
|||
Words = 4000, words_text;
|
||||
/// A client's retry token: a lowercase D-format UUID.
|
||||
RequestId = 36, uuid_text;
|
||||
/// A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a
|
||||
/// handle is never one.
|
||||
#[schemars(extend("pattern" = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))]
|
||||
AccountId = 36, uuid_text;
|
||||
/// A key label: `[A-Za-z0-9_-]{1,64}`.
|
||||
Label = 64, label_text;
|
||||
/// A campaign slug or key segment: label parts joined by `.`.
|
||||
|
|
@ -885,8 +889,13 @@ value_types! {
|
|||
pub struct PipelineDeskAnswer {
|
||||
campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option<Short>, note: Option<Words>,
|
||||
answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex,
|
||||
answerer: Short, authority: DeskAuthority, outcome: DeskOutcome,
|
||||
answerer: AccountId, authority: DeskAuthority, outcome: DeskOutcome,
|
||||
}
|
||||
/// A fork the Mind lists: members who are not on a call's decider list may read the question
|
||||
/// while this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw
|
||||
/// that). One per question, keyed by the question's label; the receipt says who listed it and
|
||||
/// when, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.
|
||||
pub struct PipelineDeskListing { campaign: Slug, listed: Short, reason: Line }
|
||||
pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec<VerdictClaim>[64] }
|
||||
pub struct PipelineFinding {
|
||||
campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence,
|
||||
|
|
@ -1425,6 +1434,16 @@ pipeline_kinds! {
|
|||
"epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument";
|
||||
DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer",
|
||||
"epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument";
|
||||
DeskListing(PipelineDeskListing) => EpiphanyPipelineDeskListingDocument, "desk_listing",
|
||||
"epiphany.pipeline.desk_listing.v2", "EpiphanyPipelineDeskListingDocument";
|
||||
}
|
||||
|
||||
impl AccountId {
|
||||
/// Whether this is a lowercase D-format UUID. For the callers outside the crate, which have no way
|
||||
/// to reach `Bounded`; on the pattern of `Slug::validate_slug`.
|
||||
pub fn validate_account(&self) -> Result<(), PipelineRefusal> {
|
||||
self.validate("account")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a full document id: the grammar read backwards. A key and an id are
|
||||
|
|
@ -1516,7 +1535,7 @@ const SUBJECT_LOCAL_MAX: usize = 64;
|
|||
|
||||
/// The longest kind name a resolution can name as its subject (`stewardship`),
|
||||
/// checked against `PipelineKind::ALL` by a test.
|
||||
const LONGEST_KIND_NAME: usize = "stewardship".len();
|
||||
const LONGEST_KIND_NAME: usize = "desk_listing".len();
|
||||
|
||||
/// A `.n<sequence>` part at `u32::MAX`.
|
||||
const SEQUENCE_PART_MAX: usize = ".n".len() + "4294967295".len();
|
||||
|
|
@ -1651,6 +1670,10 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefus
|
|||
let sequence = format!("n{}", value.sequence);
|
||||
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &sequence])?)
|
||||
}
|
||||
D::DeskListing(value) => {
|
||||
let label = parent_local("desk_listing.listed", &value.listed.0, &value.campaign.0, PipelineKind::Question)?;
|
||||
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label])?)
|
||||
}
|
||||
D::Verdict(value) => {
|
||||
let report = parent_local("verdict.cut_report", &value.cut_report.0, &value.campaign.0, PipelineKind::CutReport)?;
|
||||
let cut = parent_cut("verdict.cut_report", report, 'h')?;
|
||||
|
|
@ -1927,9 +1950,12 @@ mod tests {
|
|||
campaign: slug(CAMPAIGN), fork: id("question", "Q1"), sequence: 1, options_hash: Sha256Hex("c".repeat(64)),
|
||||
choice: Some(s("a")), note: Some("Share it, with the mark.".into()), answered_at: s("2026-11-10T15:00:00Z"),
|
||||
received_at: s("2026-11-10T15:00:03Z"), claim_in_future: false, request_id: RequestId("6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13".into()), body_sha256: Sha256Hex("d".repeat(64)),
|
||||
answerer: s("user-7"), authority: DeskAuthority::Member,
|
||||
answerer: AccountId("5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f".into()), authority: DeskAuthority::Member,
|
||||
outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") },
|
||||
}), format!("{CAMPAIGN}:desk_answer:Q1.n1")),
|
||||
(D::DeskListing(PipelineDeskListing {
|
||||
campaign: slug(CAMPAIGN), listed: id("question", "Q1"), reason: "Members may read why the Mind lists its own forks.".into(),
|
||||
}), format!("{CAMPAIGN}:desk_listing:Q1")),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -3587,7 +3613,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
assert_eq!(widest, RESOLUTION_LOCAL_MAX, "the bound is exactly the widest depth-two chain");
|
||||
assert_eq!(RESOLUTION_LOCAL_MAX, 111);
|
||||
assert_eq!(RESOLUTION_LOCAL_MAX, 112);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4109,7 +4135,7 @@ mod tests {
|
|||
};
|
||||
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?;
|
||||
let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf");
|
||||
assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds");
|
||||
assert_eq!(PipelineKind::ALL.len(), 22, "twenty-two kinds");
|
||||
assert_eq!(
|
||||
variants.iter().map(tag).collect::<Vec<_>>(),
|
||||
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
|
||||
|
|
@ -4639,7 +4665,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn desk_answer_sample() -> PipelineDeskAnswer {
|
||||
let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() };
|
||||
let PipelineDocument::DeskAnswer(answer) = samples().remove(20).0 else { unreachable!() };
|
||||
answer
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ macro_rules! kinds {
|
|||
|
||||
kinds!(
|
||||
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship,
|
||||
hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer,
|
||||
hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer, desk_listing,
|
||||
);
|
||||
|
||||
/// The schema named, or the names when none is: the exit code is 2 for a name
|
||||
|
|
|
|||
|
|
@ -1145,12 +1145,12 @@ fn schema_prints_the_published_files() {
|
|||
let names = run(&[], &["schema"], None);
|
||||
assert_eq!(names.code, 0);
|
||||
let names: Vec<&str> = names.out.lines().collect();
|
||||
assert_eq!(names.len(), 22, "{names:?}");
|
||||
assert_eq!(names.len(), 23, "{names:?}");
|
||||
assert_eq!(names[0], "campaign");
|
||||
assert_eq!(names[14], "pulse");
|
||||
assert_eq!(names[15], "memory");
|
||||
assert_eq!((names[16], names[17]), ("research", "idea"));
|
||||
assert_eq!((names[18], names[19], names[20], names[21]), ("reply", "thought", "desk_answer", "selection"));
|
||||
assert_eq!((names[18], names[19], names[20], names[21], names[22]), ("reply", "thought", "desk_answer", "desk_listing", "selection"));
|
||||
for kind in PipelineKind::ALL {
|
||||
assert!(names.contains(&kind.name()), "{}", kind.name());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
|
|||
use cultnet_rs::Selection;
|
||||
use crate::sources::forge::HttpForge;
|
||||
use huginn_mind::desk_wire::{DeskRequest, DeskResponse};
|
||||
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
|
||||
use huginn_mind::eureka_pipeline::{AccountId, PipelineRef, Short, Slug};
|
||||
use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
|
||||
use huginn_mind::{ChatHits, ChatQuery, ChatWindow, ChatWindowQuery, Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
|
||||
|
||||
|
|
@ -112,6 +112,15 @@ impl Handled {
|
|||
/// What the log says of the hits the mind held no document for: how many, and
|
||||
/// the first few by name (a restored mind can leave hundreds), or nothing when
|
||||
/// the mind held them all.
|
||||
/// The door checks an account id; the Mind's desk calls take it as the text it is.
|
||||
fn short_id(id: AccountId) -> Short {
|
||||
Short(id.0)
|
||||
}
|
||||
|
||||
fn short_ids(ids: &[AccountId]) -> Vec<Short> {
|
||||
ids.iter().map(|id| Short(id.0.clone())).collect()
|
||||
}
|
||||
|
||||
fn dropped_note(dropped: &[PipelineRef]) -> Option<String> {
|
||||
if dropped.is_empty() {
|
||||
return None;
|
||||
|
|
@ -271,10 +280,10 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
|
|||
}
|
||||
match request {
|
||||
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
|
||||
self.mind.desk_answer(&deciders, subject, authority, request, now)
|
||||
self.mind.desk_answer(&short_ids(&deciders), short_id(subject), authority, request, now)
|
||||
}
|
||||
DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&subject, &deciders, status, cursor, limit),
|
||||
DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&subject, &deciders, &fork_id),
|
||||
DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&short_id(subject), &short_ids(&deciders), status, cursor, limit),
|
||||
DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&short_id(subject), &short_ids(&deciders), &fork_id),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,20 +85,26 @@ fn wire(hash: &str, request_id: &str) -> ForkAnswerRequest {
|
|||
}
|
||||
}
|
||||
|
||||
/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID.
|
||||
fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId {
|
||||
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
|
||||
huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
|
||||
}
|
||||
|
||||
const R1: &str = "00000000-0000-4000-8000-000000000001";
|
||||
const R2: &str = "00000000-0000-4000-8000-000000000002";
|
||||
const R3: &str = "00000000-0000-4000-8000-000000000003";
|
||||
|
||||
fn answer(who: &str, authority: DeskAuthority, deciders: &[&str], request: ForkAnswerRequest) -> DeskRequest {
|
||||
DeskRequest::Answer { mind: slug(INSTANCE), subject: short(who), authority, deciders: deciders.iter().map(|d| short(d)).collect(), request }
|
||||
DeskRequest::Answer { mind: slug(INSTANCE), subject: acct(who), authority, deciders: deciders.iter().map(|d| acct(d)).collect(), request }
|
||||
}
|
||||
|
||||
/// A list read by `who` against `deciders`.
|
||||
fn forks(who: &str, deciders: &[&str], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: u8) -> DeskRequest {
|
||||
DeskRequest::Forks {
|
||||
mind: slug(INSTANCE),
|
||||
subject: short(who),
|
||||
deciders: deciders.iter().map(|d| short(d)).collect(),
|
||||
subject: acct(who),
|
||||
deciders: deciders.iter().map(|d| acct(d)).collect(),
|
||||
status,
|
||||
cursor,
|
||||
limit: ForkLimit::new(limit).unwrap(),
|
||||
|
|
@ -110,7 +116,7 @@ fn forks_all(deciders: &[&str]) -> DeskRequest {
|
|||
}
|
||||
|
||||
fn fork_read(who: &str, deciders: &[&str], fork_id: &str) -> DeskRequest {
|
||||
DeskRequest::Fork { mind: slug(INSTANCE), subject: short(who), deciders: deciders.iter().map(|d| short(d)).collect(), fork_id: fork_id.into() }
|
||||
DeskRequest::Fork { mind: slug(INSTANCE), subject: acct(who), deciders: deciders.iter().map(|d| acct(d)).collect(), fork_id: fork_id.into() }
|
||||
}
|
||||
|
||||
fn page_of(response: DeskResponse) -> (ForkList, u64) {
|
||||
|
|
@ -567,7 +573,7 @@ mod door_smoke {
|
|||
claim_in_future: false,
|
||||
request_id: RequestId(R1.into()),
|
||||
body_sha256: Sha256Hex("0".repeat(64)),
|
||||
answerer: short("x"),
|
||||
answerer: acct("x"),
|
||||
authority: DeskAuthority::Operator,
|
||||
outcome: DeskOutcome::Counted,
|
||||
});
|
||||
|
|
@ -801,7 +807,7 @@ mod door_smoke {
|
|||
assert_eq!((listed_at, read_at), (before.1 as u64, before.1 as u64), "both read at the head the mind held");
|
||||
assert_eq!(list.stamp, format!("s{listed_at}"));
|
||||
assert_eq!(list.forks[0], entry);
|
||||
assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), ["x".to_string(), "y".to_string()].as_slice()));
|
||||
assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), [acct("x").0, acct("y").0].as_slice()));
|
||||
assert_eq!(rig.state(), before, "a read stores nothing");
|
||||
assert!(rig.index.seen.lock().unwrap().is_empty());
|
||||
// An unknown fork is the answer path's refusal.
|
||||
|
|
@ -864,11 +870,19 @@ mod door_smoke {
|
|||
let note = "Keep it local: the author's call.";
|
||||
rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer {
|
||||
mind: slug(INSTANCE),
|
||||
subject: short("y"),
|
||||
subject: acct("y"),
|
||||
authority: DeskAuthority::Member,
|
||||
deciders: vec![short("y")],
|
||||
deciders: vec![acct("y")],
|
||||
request: noted(&hash, R1, note),
|
||||
})));
|
||||
// Unlisted: the outsider is shown nothing at all, and a read is `fork_unknown`.
|
||||
let (hidden, unknown) = rig.serving(|path| (reply_of(path, "m-1", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-1b", &fork_read("outsider", &["x"], FORK))));
|
||||
assert_eq!(page_of(hidden).0.forks.len(), 0);
|
||||
assert!(matches!(unknown, DeskResponse::NotStored(_)), "{unknown:?}");
|
||||
// The Mind lists it, on its own door.
|
||||
let listing = PipelineDocument::DeskListing(huginn_mind::eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: short(FORK), reason: "Members may read why.".into() });
|
||||
let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![listing])), now()).answered();
|
||||
assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}");
|
||||
let (list, read) = rig.serving(|path| (reply_of(path, "m-2", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-3", &fork_read("outsider", &["x"], FORK))));
|
||||
let (list, _) = page_of(list);
|
||||
let (read, _) = entry_of(read);
|
||||
|
|
@ -882,8 +896,8 @@ mod door_smoke {
|
|||
}
|
||||
let seen = rig.serving(|path| reply_of(path, "m-4", &fork_read("x", &["x"], FORK)));
|
||||
let (seen, _) = entry_of(seen);
|
||||
assert_eq!(seen.card.deciders, ["x"]);
|
||||
assert_eq!(seen.record.answers[0].answerer.as_deref(), Some("y"));
|
||||
assert_eq!(seen.card.deciders, [acct("x").0]);
|
||||
assert_eq!(seen.record.answers[0].answerer.as_deref(), Some(acct("y").0.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -902,7 +916,7 @@ mod door_smoke {
|
|||
let refused = rig.serving(|path| failure_of(path, &bytes));
|
||||
assert_eq!(refused.code, "payload-not-a-request", "{what}");
|
||||
assert!(refused.message.contains("deciders"), "{what}: {}", refused.message);
|
||||
assert!(!refused.message.contains("d64"), "{what} echoes a decider: {}", refused.message);
|
||||
assert!(!refused.message.contains(&acct("d64").0), "{what} echoes a decider: {}", refused.message);
|
||||
}
|
||||
assert_eq!(rig.state(), before, "nothing stored, head unchanged");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,12 +191,18 @@ impl Drop for Daemon {
|
|||
}
|
||||
}
|
||||
|
||||
/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID.
|
||||
fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId {
|
||||
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
|
||||
huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
|
||||
}
|
||||
|
||||
fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
|
||||
DeskRequest::Answer {
|
||||
mind: INSTANCE.into(),
|
||||
subject: short(who),
|
||||
subject: acct(who),
|
||||
authority: DeskAuthority::Operator,
|
||||
deciders: vec![short(who)],
|
||||
deciders: vec![acct(who)],
|
||||
request: ForkAnswerRequest {
|
||||
schema_version: RequestSchema::V0,
|
||||
fork_id: FORK.into(),
|
||||
|
|
@ -213,8 +219,8 @@ fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
|
|||
fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest {
|
||||
DeskRequest::Forks {
|
||||
mind: INSTANCE.into(),
|
||||
subject: short(who),
|
||||
deciders: deciders.iter().map(|d| short(d)).collect(),
|
||||
subject: acct(who),
|
||||
deciders: deciders.iter().map(|d| acct(d)).collect(),
|
||||
status: None,
|
||||
cursor: None,
|
||||
limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(),
|
||||
|
|
@ -284,14 +290,14 @@ fn the_binary_serves_both_doors_and_survives_restart() {
|
|||
};
|
||||
assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.stamp.clone()), (1, 0, 1, format!("s{listed_at}")));
|
||||
assert_eq!(list.forks[0].card.options_hash, hash);
|
||||
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })
|
||||
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("x")], fork_id: FORK.into() })
|
||||
else {
|
||||
panic!("a read of the seeded fork")
|
||||
};
|
||||
assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at));
|
||||
assert_eq!(daemon.documents(), seeded);
|
||||
// `authority` is no field of a read: the payload is refused whole and nothing is answered or stored.
|
||||
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })] {
|
||||
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("x")], fork_id: FORK.into() })] {
|
||||
let mut value = serde_json::to_value(&request).unwrap();
|
||||
let variant = if operation == "forks" { "Forks" } else { "Fork" };
|
||||
value[variant]["authority"] = serde_json::json!("Operator");
|
||||
|
|
@ -319,16 +325,20 @@ fn the_binary_serves_both_doors_and_survives_restart() {
|
|||
assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1")));
|
||||
let stored = daemon.documents();
|
||||
assert_eq!(stored, seeded + 2);
|
||||
// A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code, and is told no account id.
|
||||
// A subject the call's deciders do not name is a non-decider: the fork is unlisted, so it reads as a missing one.
|
||||
let outsider = ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("outsider"), deciders: vec![acct("y")], fork_id: FORK.into() });
|
||||
assert!(matches!(outsider, DeskResponse::NotStored(ref refusal) if refusal.code == huginn_mind::desk_wire::ForkRefusalCode::ForkUnknown), "{outsider:?}");
|
||||
// A decider reads both answers, the first with its choice and the stale one with its code, and is told who answered.
|
||||
let DeskResponse::Fork { entry, .. } =
|
||||
ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() })
|
||||
ask(&desk, "m-r4", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("y"), deciders: vec![acct("y")], fork_id: FORK.into() })
|
||||
else {
|
||||
panic!("a read")
|
||||
};
|
||||
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_deref(), answer.code)).collect();
|
||||
assert_eq!(seen, [("n1", None, None), ("n2", None, Some(ForkAnswerCode::StaleFork))]);
|
||||
let x = acct("x").0;
|
||||
assert_eq!(seen, [("n1", Some(x.as_str()), None), ("n2", Some(x.as_str()), Some(ForkAnswerCode::StaleFork))]);
|
||||
assert_eq!((entry.record.counted_answer_id.as_deref(), entry.record.answerer_count), (Some("n1"), 1));
|
||||
assert_eq!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[][..], 1));
|
||||
assert_eq!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[acct("y").0][..], 1));
|
||||
assert_eq!(daemon.documents(), stored);
|
||||
|
||||
// A stop takes the socket with it; the stored answers are the retry's memory.
|
||||
|
|
@ -580,7 +590,7 @@ fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest {
|
|||
}
|
||||
|
||||
fn fork_read(who: &str, fork_id: &str) -> DeskRequest {
|
||||
DeskRequest::Fork { mind: INSTANCE.into(), subject: short(who), deciders: vec![short(who)], fork_id: fork_id.into() }
|
||||
DeskRequest::Fork { mind: INSTANCE.into(), subject: acct(who), deciders: vec![acct(who)], fork_id: fork_id.into() }
|
||||
}
|
||||
|
||||
/// The wire spelling of a not-stored refusal's code, and its text.
|
||||
|
|
|
|||
|
|
@ -62,12 +62,18 @@ pub(crate) enum Door {
|
|||
Desk,
|
||||
}
|
||||
|
||||
/// Every staged `desk_answer`, whether the caller sent it or a derivation
|
||||
/// produced it, is refused unless it came by the desk door.
|
||||
fn refuse_desk_answers_outside_desk(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> {
|
||||
match docs.batch.iter().find(|staged| staged.kind == PipelineKind::DeskAnswer) {
|
||||
Some(staged) if door != Door::Desk => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }),
|
||||
_ => Ok(()),
|
||||
/// The door split. Every staged `desk_answer`, whether the caller sent it or a derivation produced it,
|
||||
/// is refused unless it came by the desk door; and a staged `desk_listing` is refused if it came by the
|
||||
/// desk door: listing is the Mind's own act, on the Admit door, with a receipt saying who.
|
||||
fn refuse_documents_at_the_wrong_door(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> {
|
||||
match docs.batch.iter().find(|staged| match staged.kind {
|
||||
PipelineKind::DeskAnswer => door != Door::Desk,
|
||||
PipelineKind::DeskListing => door == Door::Desk,
|
||||
_ => false,
|
||||
}) {
|
||||
Some(staged) if staged.kind == PipelineKind::DeskListing => Err(MindRefusal::DeskRequestInvalid { field: "desk_listing".into() }),
|
||||
Some(staged) => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +166,7 @@ impl<S: MindStore> Mind<S> {
|
|||
for envelope in envelopes {
|
||||
docs.push(stage(envelope, &mind)?)?;
|
||||
}
|
||||
refuse_desk_answers_outside_desk(door, &docs)?;
|
||||
refuse_documents_at_the_wrong_door(door, &docs)?;
|
||||
// A6
|
||||
let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1;
|
||||
if self.is_empty() && !carries_instance {
|
||||
|
|
@ -184,7 +190,7 @@ impl<S: MindStore> Mind<S> {
|
|||
docs.push(stage(envelope, &mind)?)?;
|
||||
}
|
||||
}
|
||||
refuse_desk_answers_outside_desk(door, &docs)?;
|
||||
refuse_documents_at_the_wrong_door(door, &docs)?;
|
||||
for staged in &docs.batch[originals..] {
|
||||
resolve(&docs, staged, &mind, external, &mut strong)?;
|
||||
}
|
||||
|
|
@ -627,6 +633,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo
|
|||
}
|
||||
// Decided in `answer_desk` before the document exists; no other door can stage one.
|
||||
D::DeskAnswer(_) => Ok(()),
|
||||
// The question is a citation (`listed`), so a missing one is refused with every other missing referent, and
|
||||
// the key derivation holds it to the listing's own campaign.
|
||||
D::DeskListing(_) => Ok(()),
|
||||
D::Ruling(ruling) => {
|
||||
if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator {
|
||||
return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() });
|
||||
|
|
@ -905,6 +914,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
|
|||
(K::Target, O::Superseded { by }) => all(by, K::Target),
|
||||
(K::Question, O::Answered { by }) => by.kind == K::Ruling,
|
||||
(K::Question, O::Withdrawn { .. }) => true,
|
||||
(K::DeskListing, O::Withdrawn { .. }) => true,
|
||||
(K::Ruling, O::Superseded { by }) => all(by, K::Ruling),
|
||||
(K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec),
|
||||
(K::CutSpec, O::Withdrawn { .. }) => true,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc};
|
||||
use eureka_pipeline::{
|
||||
AccountId,
|
||||
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal,
|
||||
PipelineResolution, RequestId, ResolutionOutcome, Sha256Hex, Short, Slug, Words,
|
||||
};
|
||||
|
|
@ -145,7 +146,7 @@ fn counts_under(answer: &PipelineDeskAnswer, current: &Sha256Hex) -> bool {
|
|||
/// `answer_desk` runs before it reads the fork and the door runs before its showability gate.
|
||||
pub(crate) fn bound_answer<'a>(docs: &'a Docs<'_>, answerer: &Short, request_id: &RequestId) -> Option<(&'a str, &'a PipelineDeskAnswer)> {
|
||||
docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document {
|
||||
PipelineDocument::DeskAnswer(held) if held.answerer == *answerer && held.request_id == *request_id => Some((key, held)),
|
||||
PipelineDocument::DeskAnswer(held) if held.answerer.0 == answerer.0 && held.request_id == *request_id => Some((key, held)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
@ -314,7 +315,7 @@ impl<S: MindStore> Mind<S> {
|
|||
claim_in_future,
|
||||
request_id: request.request_id.clone(),
|
||||
body_sha256: body_sha256.clone(),
|
||||
answerer: stamp.answerer.clone(),
|
||||
answerer: AccountId(stamp.answerer.0.clone()),
|
||||
authority: stamp.authority,
|
||||
outcome,
|
||||
};
|
||||
|
|
@ -425,7 +426,7 @@ mod tests {
|
|||
const LIST: [&str; 6] = ["x", "y", "z", "a", "op", "dec"];
|
||||
|
||||
fn stamp_as(who: &str, authority: DeskAuthority, list: &[&str]) -> DeskStamp {
|
||||
DeskStamp { answerer: s(who), authority, deciders: list.iter().map(|id| s(id)).collect() }
|
||||
DeskStamp { answerer: account(who), authority, deciders: list.iter().map(|id| account(id)).collect() }
|
||||
}
|
||||
|
||||
fn decider(who: &str) -> DeskStamp {
|
||||
|
|
@ -531,7 +532,7 @@ mod tests {
|
|||
let mut mind = desk();
|
||||
stored(ask(&mut mind, &decider("z"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")));
|
||||
stored(ask(&mut mind, &decider("a"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")));
|
||||
assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, s("z"));
|
||||
assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, acct("z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -780,7 +781,7 @@ mod tests {
|
|||
let record = held(&mind, &answer);
|
||||
assert_eq!(record.outcome, outcome);
|
||||
assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None });
|
||||
assert_eq!((record.answerer, record.authority, record.answered_at), (s("guest"), DeskAuthority::Steward, s(at)));
|
||||
assert_eq!((record.answerer, record.authority, record.answered_at), (acct("guest"), DeskAuthority::Steward, s(at)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -790,7 +791,7 @@ mod tests {
|
|||
for (who, authority) in [("op", DeskAuthority::Operator), ("dec", DeskAuthority::Steward), ("x", DeskAuthority::Member)] {
|
||||
let (answer, _) = stored(ask(&mut mind, &stamp_as(who, authority, &LIST), pick(FORK, "Q1", "A", at)));
|
||||
let record = held(&mind, &answer);
|
||||
assert_eq!((record.answerer, record.authority), (s(who), authority));
|
||||
assert_eq!((record.answerer, record.authority), (acct(who), authority));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1039,7 +1040,7 @@ mod tests {
|
|||
let DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") };
|
||||
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap();
|
||||
assert_eq!(receipt.provenance.faculty, faculty, "{authority:?}");
|
||||
assert_eq!(receipt.provenance.agent, s("x"));
|
||||
assert_eq!(receipt.provenance.agent, account("x"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1206,10 +1207,10 @@ mod tests {
|
|||
let image = first.image().unwrap();
|
||||
let found = Docs::at(image, image.head).find(K::DeskAnswer, &key("Q1", 1)).cloned();
|
||||
let Some(PipelineDocument::DeskAnswer(mut held)) = found else { panic!("stored") };
|
||||
held.answerer = s(&long);
|
||||
held.answerer = eureka_pipeline::AccountId(long.clone());
|
||||
store.plant(PipelineDocument::DeskAnswer(held).prepare(&cache).unwrap());
|
||||
let mut mind = opened(store, INSTANCE);
|
||||
let stamp = stamp_as(&long, DeskAuthority::Member, &LIST);
|
||||
let stamp = DeskStamp { answerer: s(&long), ..stamp_as("x", DeskAuthority::Member, &LIST) };
|
||||
let before = head(&mind);
|
||||
assert_eq!(replayed(ask(&mut mind, &stamp, request)), (key("Q1", 1), DeskOutcome::Counted));
|
||||
assert_eq!(head(&mind), before);
|
||||
|
|
@ -1262,8 +1263,8 @@ mod tests {
|
|||
assert_eq!(stored(ask(&mut mind, &decider("x"), sent.clone())), (key("Q1", 1), DeskOutcome::Counted));
|
||||
let (y_answer, y_outcome) = stored(ask(&mut mind, &decider("y"), sent.clone()));
|
||||
assert_eq!((y_answer.as_str(), &y_outcome), (key("Q1", 2).as_str(), &DeskOutcome::Revision { of: s(&key("Q1", 1)) }));
|
||||
assert_eq!(held(&mind, &y_answer).answerer, s("y"));
|
||||
assert_eq!(held(&mind, &key("Q1", 1)).answerer, s("x"));
|
||||
assert_eq!(held(&mind, &y_answer).answerer, acct("y"));
|
||||
assert_eq!(held(&mind, &key("Q1", 1)).answerer, acct("x"));
|
||||
// x's later retry still returns n1; y's returns n2.
|
||||
assert_eq!(replayed(ask(&mut mind, &decider("x"), sent.clone())).0, key("Q1", 1));
|
||||
assert_eq!(replayed(ask(&mut mind, &decider("y"), sent)).0, key("Q1", 2));
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkE
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
use eureka_pipeline::{
|
||||
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
|
||||
AccountId, DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -232,18 +232,33 @@ pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARG
|
|||
/// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
|
||||
pub const DESK_DECIDERS_MAX: usize = 64;
|
||||
|
||||
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` subjects, none twice. A
|
||||
/// bad list is the decoder's refusal, which names the field and never quotes a subject.
|
||||
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> {
|
||||
/// An account id read from the wire: a lowercase D-format UUID, or the decoder's refusal, which names the
|
||||
/// field and never quotes the value.
|
||||
fn account_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<AccountId, D::Error> {
|
||||
let id = AccountId(String::deserialize(deserializer)?);
|
||||
id.validate_account().map_err(|_| serde::de::Error::custom("not an account id: a lowercase D-format UUID"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` account ids, none twice. A
|
||||
/// bad list is the decoder's refusal, which names the field and never quotes an id.
|
||||
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<AccountId>, D::Error> {
|
||||
struct List;
|
||||
/// One list entry, through the same grammar as `subject`.
|
||||
struct Account(AccountId);
|
||||
impl<'de> Deserialize<'de> for Account {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
account_id(deserializer).map(Self)
|
||||
}
|
||||
}
|
||||
impl<'de> serde::de::Visitor<'de> for List {
|
||||
type Value = Vec<Short>;
|
||||
type Value = Vec<AccountId>;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a list of distinct deciders")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Short>, A::Error> {
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<AccountId>, A::Error> {
|
||||
let mut list = Vec::new();
|
||||
while let Some(subject) = seq.next_element::<Short>()? {
|
||||
while let Some(Account(subject)) = seq.next_element::<Account>()? {
|
||||
if list.len() == DESK_DECIDERS_MAX {
|
||||
return Err(serde::de::Error::custom("too many deciders"));
|
||||
}
|
||||
|
|
@ -264,7 +279,7 @@ fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Sho
|
|||
fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::json_schema!({
|
||||
"type": "array",
|
||||
"items": generator.subschema_for::<Short>(),
|
||||
"items": generator.subschema_for::<AccountId>(),
|
||||
"minItems": 1,
|
||||
"maxItems": DESK_DECIDERS_MAX,
|
||||
"uniqueItems": true
|
||||
|
|
@ -279,8 +294,9 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
|
|||
///
|
||||
/// The reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and
|
||||
/// `read_request` are closed and have none; one that names it is refused whole). Their `deciders`
|
||||
/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider
|
||||
/// and is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat
|
||||
/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider,
|
||||
/// sees every showable fork and is told the ids; any other reads only the forks the Mind lists
|
||||
/// (a `desk_listing` in force) and is told counts in the ids' place. `Forks` takes the contract's `list_request` fields flat
|
||||
/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost
|
||||
/// strips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
|
||||
/// reads spec.
|
||||
|
|
@ -289,19 +305,21 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem
|
|||
pub enum DeskRequest {
|
||||
Answer {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
#[serde(deserialize_with = "account_id")]
|
||||
subject: AccountId,
|
||||
authority: DeskAuthority,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
deciders: Vec<AccountId>,
|
||||
request: ForkAnswerRequest,
|
||||
},
|
||||
Forks {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
#[serde(deserialize_with = "account_id")]
|
||||
subject: AccountId,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
deciders: Vec<AccountId>,
|
||||
#[serde(deserialize_with = "required")]
|
||||
#[schemars(required)]
|
||||
status: Option<ListStatus>,
|
||||
|
|
@ -312,10 +330,11 @@ pub enum DeskRequest {
|
|||
},
|
||||
Fork {
|
||||
mind: Slug,
|
||||
subject: Short,
|
||||
#[serde(deserialize_with = "account_id")]
|
||||
subject: AccountId,
|
||||
#[serde(deserialize_with = "deciders")]
|
||||
#[schemars(schema_with = "deciders_schema")]
|
||||
deciders: Vec<Short>,
|
||||
deciders: Vec<AccountId>,
|
||||
fork_id: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -337,7 +356,7 @@ impl DeskRequest {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn deciders(&self) -> &[Short] {
|
||||
pub fn deciders(&self) -> &[AccountId] {
|
||||
match self {
|
||||
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
|
||||
}
|
||||
|
|
@ -632,14 +651,14 @@ mod tests {
|
|||
}
|
||||
|
||||
fn deciders(list: &[&str]) -> Vec<Short> {
|
||||
list.iter().map(|who| s(who)).collect()
|
||||
list.iter().map(|who| account(who)).collect()
|
||||
}
|
||||
|
||||
/// The response and whether the call committed anything: a replay, a refused body and a gate
|
||||
/// refusal store nothing, so the head does not move.
|
||||
fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) {
|
||||
let before = head(mind);
|
||||
let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now());
|
||||
let response = mind.desk_answer(&deciders(list), account(who), DeskAuthority::Member, request, now());
|
||||
(response, head(mind) != before)
|
||||
}
|
||||
|
||||
|
|
@ -779,7 +798,7 @@ mod tests {
|
|||
request_id: RequestId(text("request_id").unwrap()),
|
||||
body_sha256: Sha256Hex("0".repeat(64)),
|
||||
// A withheld vector names no answerer: store a placeholder, the vector is the non-decider's view of it.
|
||||
answerer: Short(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())),
|
||||
answerer: AccountId(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())),
|
||||
authority,
|
||||
outcome,
|
||||
};
|
||||
|
|
@ -1355,9 +1374,9 @@ mod tests {
|
|||
let (other, committed) = ask(&mut mind, "y", request.clone());
|
||||
let (other, _, _) = answered(other);
|
||||
assert!(committed);
|
||||
assert_eq!((other.answer_id.as_str(), other.answerer.as_deref().unwrap(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str()));
|
||||
assert_eq!((other.answer_id.as_str(), other.answerer.as_deref().unwrap(), other.outcome.as_str(), other.request_id.as_str()), ("n2", account("y").0.as_str(), "Revision", request.request_id.as_str()));
|
||||
let (original, _, _) = answered(ask(&mut mind, "x", request.clone()).0);
|
||||
assert_eq!((original.answer_id.as_str(), original.answerer.as_deref().unwrap()), ("n1", "x"));
|
||||
assert_eq!((original.answer_id.as_str(), original.answerer.as_deref().unwrap()), ("n1", account("x").0.as_str()));
|
||||
// V17 and V18: an id that is no lowercase D-format UUID is body_invalid naming request_id, and binds nothing.
|
||||
let before = head(&mind);
|
||||
for bad in ["retry-1", "0000000A-0000-4000-8000-00000000000B", "{00000000-0000-4000-8000-000000000001}", "00000000-0000-4000-8000-0000000000010", "", " 00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000001 ", "00000000-0000-4000-8000-000000000001\n", "00000000-0000-4000-8000-000000000001x"] {
|
||||
|
|
@ -1568,27 +1587,27 @@ mod tests {
|
|||
let mut mind = desk();
|
||||
let hash = hash_of(&mind, FORK);
|
||||
// The operator's own account, off the list, is no decider; a member on the list is.
|
||||
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
|
||||
let off = mind.desk_answer(&deciders(&["x"]), account("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
|
||||
assert_eq!(answered(off).0.code, Some(ForkAnswerCode::NotADecider));
|
||||
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now());
|
||||
let on = mind.desk_answer(&deciders(&["m"]), account("m"), DeskAuthority::Member, wire(FORK, &hash), now());
|
||||
let (answer, _, _) = answered(on);
|
||||
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, "m"));
|
||||
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, account("m").0.as_str()));
|
||||
}
|
||||
|
||||
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----
|
||||
|
||||
use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
|
||||
|
||||
fn list_of(n: usize) -> Vec<Short> {
|
||||
(0..n).map(|at| s(&format!("d{at}"))).collect()
|
||||
fn list_of(n: usize) -> Vec<AccountId> {
|
||||
(0..n).map(|at| acct(&format!("d{at}"))).collect()
|
||||
}
|
||||
|
||||
/// Each request kind of the desk, carrying `list`.
|
||||
fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] {
|
||||
fn desk_requests(mind: &str, list: &[AccountId], hash: &str) -> [DeskRequest; 3] {
|
||||
[
|
||||
DeskRequest::Answer { mind: slug(mind), subject: s("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
|
||||
DeskRequest::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
|
||||
DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
|
||||
DeskRequest::Answer { mind: slug(mind), subject: acct("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
|
||||
DeskRequest::Forks { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
|
||||
DeskRequest::Fork { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1611,7 +1630,7 @@ mod tests {
|
|||
// 64 deciders through to the Mind: the subject is listed and counts.
|
||||
let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&desk_requests(INSTANCE, &list_of(64), &hash)[0]).unwrap() else { panic!("not an answer") };
|
||||
let before = head(&mind);
|
||||
let response = mind.desk_answer(&deciders, subject, authority, request, now());
|
||||
let response = mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::<Vec<_>>(), s(&subject.0), authority, request, now());
|
||||
assert!(head(&mind) > before);
|
||||
assert_eq!(answered(response).0.outcome, "Counted");
|
||||
|
||||
|
|
@ -1631,7 +1650,7 @@ mod tests {
|
|||
let hash = hash_of(&mind, FORK);
|
||||
let before = (head(&mind), stored_count(&mind, FORK));
|
||||
let mut twice = list_of(3);
|
||||
twice.push(s("d1"));
|
||||
twice.push(acct("d1"));
|
||||
for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
|
||||
for sent in desk_requests(INSTANCE, &list, &hash) {
|
||||
let failure = through_the_door(&sent).unwrap_err();
|
||||
|
|
@ -1667,6 +1686,6 @@ mod tests {
|
|||
// The same request to the mind it names is answered.
|
||||
let DeskRequest::Answer { mind: named, subject, authority, deciders, request } = through_the_door(&desk_requests(INSTANCE, &list_of(4), &hash)[0]).unwrap() else { panic!("not an answer") };
|
||||
mind.require_instance(&named).unwrap();
|
||||
assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now())).0.outcome, "Counted");
|
||||
assert_eq!(answered(mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::<Vec<_>>(), s(&subject.0), authority, request, now())).0.outcome, "Counted");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
fn visible(docs: &Docs<'_>, key: &str, decider: bool) -> bool {
|
||||
decider || {
|
||||
let (root, label) = root_and_local(key);
|
||||
let listing = format!("{root}:desk_listing:{label}");
|
||||
// `in_force` is true of a document nobody wrote: a listing counts only if the Mind holds it.
|
||||
docs.in_image(PipelineKind::DeskListing, &listing).is_some() && docs.in_force(PipelineKind::DeskListing, &listing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every question the Mind holds at the snapshot, showable or not, with its rank: the 0-based position
|
||||
/// in ascending (admission ordinal, key) order. A rank is unique, and admissions only append, so a held
|
||||
/// question's rank never moves; a question the deciders do not show leaves a gap and shifts no other.
|
||||
|
|
@ -427,15 +439,17 @@ impl<S: MindStore> Mind<S> {
|
|||
/// The fork itself is still its whole entry on `desk_fork`; if that entry is past the reply cap the door
|
||||
/// answers the read with `fork_too_large` naming the fork (no order, no cursor), not `ResponseTooLarge`. Nothing is dropped silently.
|
||||
///
|
||||
/// The call's `deciders` feed the showability gate; the call's `subject` decides which view of each entry
|
||||
/// is built (a decider's, with ids, or a non-decider's, with counts). It gates no fork. Notes stay whole
|
||||
/// (Bifrost strips them).
|
||||
/// The call's `deciders` feed the showability gate; the call's `subject` decides which forks are visible and
|
||||
/// which view of each entry is built. A decider sees every showable fork, with ids; any other reader sees a
|
||||
/// showable fork only while the Mind's `desk_listing` for it is in force, with counts. The counts, the
|
||||
/// pages and the cursor are over the visible set; `order` and `stamp` are not (a non-decider sees gaps).
|
||||
/// Notes stay whole (Bifrost strips them).
|
||||
pub fn desk_forks(&self, subject: &Short, deciders: &[Short], status: Option<ListStatus>, cursor: Option<ForkCursor>, limit: ForkLimit) -> DeskResponse {
|
||||
self.forks_page(subject, deciders, status, cursor, limit).unwrap_or_else(door_refusal)
|
||||
}
|
||||
|
||||
/// One fork, as the entry a page carries, read at one snapshot. A fork that is not held, is not a
|
||||
/// question or is not showable is `fork_unknown`, the answer path's refusal. The view is the reader's, as `desk_forks`.
|
||||
/// question, is not showable or is not visible to the reader is `fork_unknown`, the answer path's refusal, with the same bytes as a missing fork. The view is the reader's, as `desk_forks`.
|
||||
pub fn desk_fork(&self, subject: &Short, deciders: &[Short], fork_id: &str) -> DeskResponse {
|
||||
self.fork_entry(subject, deciders, fork_id).unwrap_or_else(door_refusal)
|
||||
}
|
||||
|
|
@ -444,7 +458,7 @@ impl<S: MindStore> Mind<S> {
|
|||
let image = self.image()?;
|
||||
let docs = Docs::at(image, image.head);
|
||||
let decider = deciders.contains(subject);
|
||||
let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, _, question)| showable(question, deciders)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect();
|
||||
let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, key, question)| showable(question, deciders) && visible(&docs, key, decider)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect();
|
||||
shown.reverse();
|
||||
let open_count = shown.iter().filter(|fork| fork.3).count() as u64;
|
||||
let recorded_count = shown.len() as u64 - open_count;
|
||||
|
|
@ -499,7 +513,7 @@ impl<S: MindStore> Mind<S> {
|
|||
let Some((order, key, question)) = ranked(&docs)?.into_iter().find(|(_, key, _)| *key == fork_id) else {
|
||||
return Ok(DeskResponse::NotStored(fork_unknown()));
|
||||
};
|
||||
if !showable(question, deciders) {
|
||||
if !showable(question, deciders) || !visible(&docs, key, decider) {
|
||||
return Ok(DeskResponse::NotStored(fork_unknown()));
|
||||
}
|
||||
Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders, decider)?, as_of: image.head })
|
||||
|
|
@ -536,7 +550,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn list(who: &[&str]) -> Vec<Short> {
|
||||
who.iter().map(|id| s(id)).collect()
|
||||
who.iter().map(|id| account(id)).collect()
|
||||
}
|
||||
|
||||
fn head(mind: &Mind<MemoryStore>) -> u64 {
|
||||
|
|
@ -568,7 +582,7 @@ mod tests {
|
|||
answered_at: "2026-09-16T11:00:00Z".into(),
|
||||
request_id: request_id(),
|
||||
};
|
||||
mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now())
|
||||
mind.desk_answer(&list(deciders), account(who), DeskAuthority::Member, request, now())
|
||||
}
|
||||
|
||||
fn close(mind: &mut Mind<MemoryStore>, fork: &str, label: &str) {
|
||||
|
|
@ -605,7 +619,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn all(mind: &Mind<MemoryStore>, n: u8) -> ForkList {
|
||||
page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(n))).0
|
||||
page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(n))).0
|
||||
}
|
||||
|
||||
// ---- the reads, one snapshot ----
|
||||
|
|
@ -616,7 +630,7 @@ mod tests {
|
|||
let stale = answer(&mut mind, "x", &LIST, &fork(1), &"0".repeat(64), None);
|
||||
let DeskResponse::Answered { answer: stale, .. } = stale else { panic!("a stale answer is stored") };
|
||||
assert_eq!(stale.code, Some(ForkAnswerCode::StaleFork));
|
||||
let (shown, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (shown, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
assert_eq!(shown.card.options_hash, hash_of(&mind, &fork(1)));
|
||||
assert_ne!(shown.card.options_hash, "0".repeat(64));
|
||||
assert_eq!((shown.record.answers.len(), shown.record.counted_answer_id.clone()), (1, None));
|
||||
|
|
@ -627,7 +641,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!((counted.outcome.as_str(), counted.answer_id.as_str()), ("Counted", "n2"));
|
||||
assert_eq!(counted_answer_id.as_deref(), Some("n2"));
|
||||
let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (after, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
|
||||
let names: Vec<_> = after.record.answers.iter().map(|answer| answer.answer_id.as_str()).collect();
|
||||
assert_eq!(names, ["n1", "n2"], "answers in admission order, named n<seq>");
|
||||
|
|
@ -636,23 +650,23 @@ mod tests {
|
|||
#[test]
|
||||
fn list_then_read_at_one_stamp() {
|
||||
let mut mind = desk(3);
|
||||
let (first, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
let (first, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
assert_eq!(as_of, head(&mind));
|
||||
assert_eq!(first.stamp, format!("s{as_of}"));
|
||||
assert_eq!(first.mind, INSTANCE);
|
||||
assert_eq!(ids(&first), [fork(3), fork(2)]);
|
||||
let next = first.next_cursor.expect("one more fork waits");
|
||||
assert_eq!(next.order(), first.forks[1].card.order);
|
||||
let (second, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(next), limit(2)));
|
||||
let (second, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, Some(next), limit(2)));
|
||||
assert_eq!((ids(&second), second.next_cursor), (vec![fork(1)], None));
|
||||
// The read at that stamp is the entry the list carried.
|
||||
let (read, read_at) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2)));
|
||||
let (read, read_at) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2)));
|
||||
assert_eq!(read_at, as_of);
|
||||
assert_eq!(read, first.forks[1]);
|
||||
// Writing moves the stamp, and only then.
|
||||
let hash = hash_of(&mind, &fork(2));
|
||||
answer(&mut mind, "x", &LIST, &fork(2), &hash, None);
|
||||
let (moved, moved_at) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
let (moved, moved_at) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
assert_eq!((moved.stamp.clone(), moved_at), (format!("s{}", head(&mind)), head(&mind)));
|
||||
assert_ne!(moved.stamp, first.stamp);
|
||||
assert_eq!(moved.forks[1].record.answers.len(), 1);
|
||||
|
|
@ -671,17 +685,17 @@ mod tests {
|
|||
assert_eq!(ForkCursor::new(0), None, "no cursor names rank 0: nothing is below it");
|
||||
for (at, expected) in [(0, 3), (1, 2), (2, 1)] {
|
||||
let below = ForkCursor::new(orders[at]).unwrap();
|
||||
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(below), limit(50)));
|
||||
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, Some(below), limit(50)));
|
||||
assert_eq!(rest.forks.len(), expected, "below order {}", orders[at]);
|
||||
assert!(rest.forks.iter().all(|entry| entry.card.order < orders[at]));
|
||||
}
|
||||
// A page that stops short names the last order it returned.
|
||||
let (short, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
|
||||
let (short, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(1)));
|
||||
assert_eq!((ids(&short), short.next_cursor.map(ForkCursor::order)), (vec![fork(4)], Some(orders[0])));
|
||||
// A page that ends exactly on the last fork has no cursor.
|
||||
let (exact, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(4)));
|
||||
let (exact, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(4)));
|
||||
assert_eq!((exact.forks.len(), exact.next_cursor), (4, None));
|
||||
let (over, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
let (over, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
assert_eq!(over.forks.len(), 4);
|
||||
}
|
||||
|
||||
|
|
@ -694,7 +708,7 @@ mod tests {
|
|||
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
|
||||
for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] {
|
||||
for size in [0, 1, 50] {
|
||||
let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
|
||||
let (shown, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), status, cursor, limit(size)));
|
||||
assert_eq!((shown.open_count, shown.recorded_count), expected, "{status:?} {cursor:?} {size}");
|
||||
assert!(shown.forks.len() <= size as usize);
|
||||
for entry in &shown.forks {
|
||||
|
|
@ -708,31 +722,31 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
let (open, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50)));
|
||||
let (open, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50)));
|
||||
assert_eq!(ids(&open), [fork(5), fork(3), fork(2)]);
|
||||
let (recorded, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50)));
|
||||
let (recorded, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50)));
|
||||
assert_eq!(ids(&recorded), [fork(4), fork(1)]);
|
||||
// A cursor and a status together: the cards below the cursor that have the status.
|
||||
let below_four = ForkCursor::new(recorded.forks[0].card.order);
|
||||
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50)));
|
||||
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50)));
|
||||
assert_eq!(ids(&rest), [fork(3), fork(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() {
|
||||
let mind = desk(3);
|
||||
let (counts, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(0)));
|
||||
let (counts, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(0)));
|
||||
assert_eq!((counts.forks.len(), counts.next_cursor), (0, None));
|
||||
assert_eq!((counts.open_count, counts.recorded_count), (3, 0));
|
||||
assert_eq!((counts.stamp, as_of), (format!("s{}", head(&mind)), head(&mind)));
|
||||
let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1)));
|
||||
let (one, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(1)));
|
||||
assert_eq!(one.forks.len(), 1, "limit 1 is one entry, not none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_desk_with_no_forks_is_an_empty_page() {
|
||||
let mind = seeded();
|
||||
let (empty, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(10)));
|
||||
let (empty, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(10)));
|
||||
assert_eq!((empty.open_count, empty.recorded_count, empty.forks.len(), empty.next_cursor), (0, 0, 0, None));
|
||||
assert_eq!(empty.stamp, format!("s{as_of}"));
|
||||
}
|
||||
|
|
@ -757,11 +771,11 @@ mod tests {
|
|||
let shown = all(&mind, 50);
|
||||
assert_eq!(ids(&shown), [fork(2), fork(1)]);
|
||||
assert_eq!((shown.open_count, shown.recorded_count), (2, 0), "the count is over the showable set");
|
||||
unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), &unshowable));
|
||||
unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), &unshowable));
|
||||
// No decider, nothing is shown at all: the gate needs one.
|
||||
let (none, _) = page(mind.desk_forks(&s(LIST[0]), &[], None, None, limit(50)));
|
||||
let (none, _) = page(mind.desk_forks(&account(LIST[0]), &[], None, None, limit(50)));
|
||||
assert_eq!((none.forks.len(), none.open_count, none.recorded_count), (0, 0, 0));
|
||||
unknown(mind.desk_fork(&s(LIST[0]), &[], &fork(1)));
|
||||
unknown(mind.desk_fork(&account(LIST[0]), &[], &fork(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -770,12 +784,12 @@ mod tests {
|
|||
close(&mut mind, &fork(1), "R1");
|
||||
let ruling_key = format!("{CAMPAIGN_ROOT}:ruling:R1");
|
||||
for id in [fork(9).as_str(), "", "Q1", "no-colons", "eureka-state:question:", ruling_key.as_str(), &"x".repeat(5000), "other-campaign:question:Q1"] {
|
||||
unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), id));
|
||||
unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), id));
|
||||
}
|
||||
// And nothing was written by looking.
|
||||
let before = head(&mind);
|
||||
let _ = mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1));
|
||||
let _ = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50));
|
||||
let _ = mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1));
|
||||
let _ = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50));
|
||||
assert_eq!(head(&mind), before);
|
||||
}
|
||||
|
||||
|
|
@ -790,6 +804,9 @@ mod tests {
|
|||
/// and Ben answers a second time (a retraction): four stored answers, three answerers.
|
||||
fn answered_desk() -> Mind<MemoryStore> {
|
||||
let mut mind = desk(2);
|
||||
// Listed: a reader who is not a decider sees these forks (and their counts) at all.
|
||||
list_it(&mut mind, 1);
|
||||
list_it(&mut mind, 2);
|
||||
let hash = hash_of(&mind, &fork(1));
|
||||
let deciders = [ANNA, BEN];
|
||||
answer(&mut mind, ANNA, &deciders, &fork(1), &hash, Some("Anna's note"));
|
||||
|
|
@ -812,8 +829,8 @@ mod tests {
|
|||
}
|
||||
|
||||
fn reader(mind: &Mind<MemoryStore>, subject: &str, deciders: &[&str]) -> (ForkList, ForkEntry, Vec<u8>) {
|
||||
let (page, _) = page(mind.desk_forks(&s(subject), &list(deciders), None, None, limit(50)));
|
||||
let response = mind.desk_fork(&s(subject), &list(deciders), &fork(1));
|
||||
let (page, _) = page(mind.desk_forks(&account(subject), &list(deciders), None, None, limit(50)));
|
||||
let response = mind.desk_fork(&account(subject), &list(deciders), &fork(1));
|
||||
let bytes = encode_desk_response("m", "fork", &response, "r").unwrap();
|
||||
let bytes = cultnet_rs::encode_cultnet_message_to_vec(&bytes, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap();
|
||||
let (entry, _) = entry(response);
|
||||
|
|
@ -890,7 +907,7 @@ mod tests {
|
|||
// ---- raised_at: the question's first admission ----
|
||||
|
||||
fn raised_at(mind: &Mind<MemoryStore>, fork_id: &str) -> Option<String> {
|
||||
entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at
|
||||
entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -964,18 +981,189 @@ mod tests {
|
|||
#[test]
|
||||
fn raised_at_is_outside_options_hash() {
|
||||
let mind = desk(1);
|
||||
let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1)));
|
||||
let (read, _) = entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), &fork(1)));
|
||||
let moved = ForkCard { raised_at: Some("2020-01-01T00:00:00Z".into()), ..read.card.clone() };
|
||||
assert_ne!(moved.raised_at, read.card.raised_at);
|
||||
assert_eq!(moved.options_hash, read.card.options_hash);
|
||||
assert_eq!(read.card.options_hash, hash_of(&mind, &fork(1)));
|
||||
}
|
||||
|
||||
// ---- the listing gate: a non-decider reads a fork only while the Mind lists it ----
|
||||
|
||||
fn listing_doc(n: u32) -> D {
|
||||
D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN_ROOT), listed: s(&fork(n)), reason: "Members may read why.".into() })
|
||||
}
|
||||
|
||||
fn listing_ref(n: u32) -> PipelineRef {
|
||||
PipelineRef { kind: PipelineKind::DeskListing, id: s(&format!("{CAMPAIGN_ROOT}:desk_listing:Q{n}")) }
|
||||
}
|
||||
|
||||
fn list_it(mind: &mut Mind<MemoryStore>, n: u32) {
|
||||
committed(admit(mind, vec![listing_doc(n)]));
|
||||
}
|
||||
|
||||
/// Unlist: a `Withdrawn` resolution on the listing, `sequence` its per-subject sequence.
|
||||
fn unlist(mind: &mut Mind<MemoryStore>, n: u32, sequence: u32) -> String {
|
||||
let withdrawal = crate::fixtures::resolution_n(listing_ref(n), sequence, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "unlisted".into() });
|
||||
let key = eureka_pipeline::pipeline_key(&withdrawal).unwrap();
|
||||
committed(admit(mind, vec![withdrawal]));
|
||||
key
|
||||
}
|
||||
|
||||
/// Relist: withdraw the withdrawal.
|
||||
fn relist(mind: &mut Mind<MemoryStore>, withdrawal: &str) {
|
||||
let subject = PipelineRef { kind: PipelineKind::Resolution, id: s(withdrawal) };
|
||||
committed(admit(mind, vec![crate::fixtures::resolution(subject, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "listed again".into() })]));
|
||||
}
|
||||
|
||||
fn seen_by(mind: &Mind<MemoryStore>, subject: &str) -> Vec<String> {
|
||||
ids(&page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, None, limit(50))).0)
|
||||
}
|
||||
|
||||
fn read_by(mind: &Mind<MemoryStore>, subject: &str, n: u32) -> DeskResponse {
|
||||
mind.desk_fork(&account(subject), &list(&[ANNA, BEN]), &fork(n))
|
||||
}
|
||||
|
||||
/// Negative first: with nothing listed, a non-decider is shown nothing, and a read of a real fork
|
||||
/// is the same bytes as a read of one that does not exist.
|
||||
#[test]
|
||||
fn an_unlisted_fork_is_invisible_to_a_non_decider() {
|
||||
let mind = desk(3);
|
||||
let (shown, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(50)));
|
||||
assert_eq!((shown.forks.len(), shown.open_count, shown.recorded_count, shown.next_cursor), (0, 0, 0, None));
|
||||
let (counts, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(0)));
|
||||
assert_eq!((counts.open_count, counts.recorded_count), (0, 0));
|
||||
let real = read_by(&mind, DAN, 1);
|
||||
unknown(real.clone());
|
||||
assert_eq!(real, read_by(&mind, DAN, 99), "a hidden fork reads as a missing one");
|
||||
let bytes = |response: &DeskResponse| rmp_serde::to_vec_named(response).unwrap();
|
||||
assert_eq!(bytes(&real), bytes(&read_by(&mind, DAN, 99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decider_sees_unlisted_forks() {
|
||||
let mut mind = desk(3);
|
||||
list_it(&mut mind, 2);
|
||||
assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]);
|
||||
assert_eq!(seen_by(&mind, DAN), [fork(2)]);
|
||||
// Listing is not read for a decider: unlisting changes nothing for them.
|
||||
unlist(&mut mind, 2, 1);
|
||||
assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]);
|
||||
assert!(seen_by(&mind, DAN).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_flips_and_is_audited() {
|
||||
let mut mind = desk(2);
|
||||
let mut states = vec![seen_by(&mind, DAN)];
|
||||
let before = head(&mind);
|
||||
list_it(&mut mind, 1);
|
||||
states.push(seen_by(&mind, DAN));
|
||||
let withdrawal = unlist(&mut mind, 1, 1);
|
||||
states.push(seen_by(&mind, DAN));
|
||||
relist(&mut mind, &withdrawal);
|
||||
states.push(seen_by(&mind, DAN));
|
||||
unlist(&mut mind, 1, 2);
|
||||
states.push(seen_by(&mind, DAN));
|
||||
let q1 = vec![fork(1)];
|
||||
assert_eq!(states, [vec![], q1.clone(), vec![], q1, vec![]]);
|
||||
assert_eq!(head(&mind), before + 4, "every flip is a document with its own receipt");
|
||||
assert!(matches!(read_by(&mind, DAN, 1), DeskResponse::NotStored(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_and_cursor_cover_the_visible_set() {
|
||||
let mut mind = desk(5);
|
||||
for n in [2, 4] {
|
||||
list_it(&mut mind, n);
|
||||
}
|
||||
close(&mut mind, &fork(4), "R4");
|
||||
let list_for = |subject: &str, cursor, size| page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, cursor, limit(size))).0;
|
||||
let counts = list_for(DAN, None, 0);
|
||||
assert_eq!((counts.open_count, counts.recorded_count), (1, 1));
|
||||
// Pages are gapless over the visible forks and the cursor never names a hidden one.
|
||||
let first = list_for(DAN, None, 1);
|
||||
assert_eq!(ids(&first), [fork(4)]);
|
||||
let cursor = first.next_cursor.expect("one more visible fork");
|
||||
let second = list_for(DAN, Some(cursor), 1);
|
||||
assert_eq!((ids(&second), second.next_cursor), (vec![fork(2)], None));
|
||||
// Order keeps its rank among ALL the Mind's questions: the gap is visible, the text is not.
|
||||
assert_eq!(orders(&first).into_iter().chain(orders(&second)).collect::<Vec<_>>(), [3, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listing_needs_a_question_of_its_campaign() {
|
||||
let mut mind = desk(1);
|
||||
let missing = refusal(admit(&mut mind, vec![listing_doc(9)]));
|
||||
assert!(matches!(missing, MindRefusal::MissingReference { kind: PipelineKind::Question, .. }), "{missing:?}");
|
||||
let D::DeskListing(mut other) = listing_doc(1) else { unreachable!() };
|
||||
other.campaign = slug("other-campaign");
|
||||
let before = head(&mind);
|
||||
assert!(matches!(admit(&mut mind, vec![D::DeskListing(other)]), PipelineAdmissionOutcome::Refused(_)));
|
||||
assert_eq!(head(&mind), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desk_door_cannot_admit_a_listing() {
|
||||
let mut mind = desk(1);
|
||||
let envelope = crate::fixtures::prepare(&listing_doc(1));
|
||||
let instance = mind.instance().clone();
|
||||
let outcome = mind.admit_steps(crate::admission::Door::Desk, &instance, provenance(Faculty::Operator), vec![envelope], now());
|
||||
assert_eq!(outcome.unwrap_err(), MindRefusal::DeskRequestInvalid { field: "desk_listing".into() });
|
||||
assert!(seen_by(&mind, DAN).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_role_is_carried_by_desk_listing_only() {
|
||||
use crate::docs::CitationRole as R;
|
||||
assert_eq!((R::Listed.carrier(), R::Listed.target_kinds(), R::Listed.name()), (PipelineKind::DeskListing, vec![PipelineKind::Question], "listed"));
|
||||
assert_eq!(R::Fork.carrier(), PipelineKind::DeskAnswer);
|
||||
assert_eq!(R::ALL.len(), 27);
|
||||
}
|
||||
|
||||
// ---- the account grammar at decode ----
|
||||
|
||||
#[test]
|
||||
fn a_decider_or_subject_outside_the_account_grammar_is_refused_at_decode() {
|
||||
let good = encode_desk_request("m", &DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }, None).unwrap();
|
||||
decode_desk_request(&good).unwrap();
|
||||
let bad_ids = ["@x:server", "meta@gamecult.org", "someone", "7", &acct("a").0.to_uppercase(), &format!("{{{}}}", acct("a").0), ""];
|
||||
for bad in bad_ids {
|
||||
for (field, edit) in [("subject", 0), ("deciders", 1)] {
|
||||
let mut value = serde_json::to_value(DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }).unwrap();
|
||||
if edit == 0 {
|
||||
value["Fork"]["subject"] = bad.into();
|
||||
} else {
|
||||
value["Fork"]["deciders"] = json!([bad]);
|
||||
}
|
||||
let sent = encode_desk_request_value(&value);
|
||||
let failure = decode_desk_request(&sent).unwrap_err();
|
||||
assert_eq!(failure.code, "payload-not-a-request", "{field}: {bad:?}");
|
||||
assert!(failure.message.contains(field), "{}", failure.message);
|
||||
assert!(bad.is_empty() || !failure.message.contains(bad), "the refusal quotes {bad:?}: {}", failure.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_desk_request_value(value: &Value) -> cultnet_rs::CultNetMessage {
|
||||
use base64::Engine;
|
||||
cultnet_rs::CultNetMessage::OperationRequest {
|
||||
message_id: "m".into(),
|
||||
service_id: DESK_SERVICE_ID.into(),
|
||||
operation: "fork".into(),
|
||||
payload_schema: DESK_REQUEST_SCHEMA.into(),
|
||||
payload_encoding: "messagepack-base64".into(),
|
||||
payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(value).unwrap()),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the card refuses a count that does not match ----
|
||||
|
||||
fn card_with(deciders: usize, decider_count: u64) -> Vec<u8> {
|
||||
let mind = desk(1);
|
||||
let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1)));
|
||||
let (read, _) = entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), &fork(1)));
|
||||
let ids: Vec<String> = (0..deciders).map(|n| format!("00000000-0000-4000-8000-{n:012x}")).collect();
|
||||
rmp_serde::to_vec_named(&ForkCard { deciders: ids, decider_count, ..read.card }).unwrap()
|
||||
}
|
||||
|
|
@ -1015,7 +1203,7 @@ mod tests {
|
|||
held.raised_in = Some(PipelineRef { kind: PipelineKind::Ruling, id: s("eureka-state:ruling:R0") });
|
||||
committed(admit(&mut mind, vec![D::Ruling(ruling("R0"))]));
|
||||
committed(admit(&mut mind, vec![shown]));
|
||||
let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (read, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let card = &read.card;
|
||||
assert_eq!((card.fork_id.as_str(), card.title.as_str(), card.context.as_str()), (fork(1).as_str(), "Share the mark?", "Should a slot mark be shared?"));
|
||||
let options: Vec<_> = card.options.iter().map(|option| (option.id.as_str(), option.text.as_str())).collect();
|
||||
|
|
@ -1024,7 +1212,7 @@ mod tests {
|
|||
assert_eq!(card.depends, ["eureka-state:question:other", "a prose line"]);
|
||||
assert_eq!(card.raised_in, Some(ForkRef { kind: "Ruling".into(), id: "eureka-state:ruling:R0".into() }));
|
||||
assert_eq!((card.leans.clone(), card.flag.clone(), card.status, card.ruling.clone(), card.ruled_from.clone()), (None, None, CardStatus::Open, None, vec![]));
|
||||
assert_eq!(card.deciders, LIST);
|
||||
assert_eq!(card.deciders, LIST.iter().map(|id| account(id).0).collect::<Vec<_>>());
|
||||
assert_eq!(card.options_hash, hash_of(&mind, &fork(1)));
|
||||
assert_eq!(card.order, 0, "the only question is rank 0");
|
||||
assert_eq!(read.record.fork_id, fork(1));
|
||||
|
|
@ -1035,9 +1223,9 @@ mod tests {
|
|||
fn a_ruling_closes_the_card_and_names_itself() {
|
||||
let mut mind = desk(2);
|
||||
close(&mut mind, &fork(1), "R1");
|
||||
let (closed, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (closed, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
assert_eq!((closed.card.status, closed.card.ruling.as_deref()), (CardStatus::Closed, Some("eureka-state:ruling:R1")));
|
||||
let (open, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2)));
|
||||
let (open, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2)));
|
||||
assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None));
|
||||
}
|
||||
|
||||
|
|
@ -1047,7 +1235,7 @@ mod tests {
|
|||
let hash = hash_of(&mind, &fork(1));
|
||||
answer(&mut mind, "x", &LIST, &fork(1), &hash, None);
|
||||
answer(&mut mind, "y", &LIST, &fork(1), &hash, None);
|
||||
let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (read, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
assert_eq!(read.record.counted_answer_id.as_deref(), Some("n1"));
|
||||
let outcomes: Vec<_> = read.record.answers.iter().map(|answer| (answer.outcome.as_str(), answer.of.as_deref())).collect();
|
||||
assert_eq!(outcomes, [("Counted", None), ("Revision", Some("n1"))]);
|
||||
|
|
@ -1062,9 +1250,9 @@ mod tests {
|
|||
answered_at: "2026-09-16T11:00:00Z".into(),
|
||||
request_id: request_id(),
|
||||
};
|
||||
let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now());
|
||||
let stored = mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retract, now());
|
||||
assert!(matches!(stored, DeskResponse::Answered { .. }));
|
||||
let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)));
|
||||
let (after, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)));
|
||||
assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2"));
|
||||
}
|
||||
|
||||
|
|
@ -1236,7 +1424,7 @@ mod tests {
|
|||
let file = transport();
|
||||
let flat = |message: &Value| {
|
||||
json!({ "Forks": {
|
||||
"mind": message["mind"], "subject": "s", "deciders": ["s"],
|
||||
"mind": message["mind"], "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"],
|
||||
"status": message["status"], "cursor": message["cursor"], "limit": message["limit"]
|
||||
}})
|
||||
};
|
||||
|
|
@ -1261,7 +1449,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn forks_json(status: Value, cursor: Value, limit: Value) -> Value {
|
||||
json!({ "Forks": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "status": status, "cursor": cursor, "limit": limit } })
|
||||
json!({ "Forks": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "status": status, "cursor": cursor, "limit": limit } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1308,7 +1496,7 @@ mod tests {
|
|||
#[test]
|
||||
fn a_read_that_names_an_authority_is_not_a_request() {
|
||||
let mut forks = forks_json(Value::Null, Value::Null, json!(5));
|
||||
let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "fork_id": fork(1) } });
|
||||
let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "fork_id": fork(1) } });
|
||||
assert!(serde_json::from_value::<DeskRequest>(forks.clone()).is_ok() && serde_json::from_value::<DeskRequest>(fork.clone()).is_ok());
|
||||
for authority in [json!("Operator"), json!("Member"), Value::Null] {
|
||||
forks["Forks"]["authority"] = authority.clone();
|
||||
|
|
@ -1317,7 +1505,7 @@ mod tests {
|
|||
assert!(serde_json::from_value::<DeskRequest>(fork.clone()).is_err(), "Fork with {authority}");
|
||||
}
|
||||
// And through the envelope the door reads.
|
||||
let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: s("s"), deciders: list(&["s"]), status: None, cursor: None, limit: limit(5) };
|
||||
let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: acct("s"), deciders: vec![acct("s")], status: None, cursor: None, limit: limit(5) };
|
||||
let message = encode_desk_request("m-1", &typed, None).unwrap();
|
||||
assert_eq!(decode_desk_request(&message).unwrap().1, typed);
|
||||
}
|
||||
|
|
@ -1355,7 +1543,7 @@ mod tests {
|
|||
fn pages(mind: &Mind<MemoryStore>, size: u8) -> Vec<(DeskResponse, ForkList)> {
|
||||
let (mut cursor, mut out) = (None, Vec::new());
|
||||
loop {
|
||||
let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, cursor, limit(size));
|
||||
let response = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, cursor, limit(size));
|
||||
let (page, _) = page(response.clone());
|
||||
cursor = page.next_cursor;
|
||||
out.push((response, page));
|
||||
|
|
@ -1406,7 +1594,7 @@ mod tests {
|
|||
}
|
||||
mind
|
||||
};
|
||||
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))).0;
|
||||
let first_page = |mind: &Mind<MemoryStore>| page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))).0;
|
||||
// The most forks that fit one page with the oldest one's text short.
|
||||
let fits = (2..=50).take_while(|forks| first_page(&build(*forks, 300)).forks.len() == *forks as usize).last().expect("two fit");
|
||||
assert!(fits < 50, "the desk outgrows a page");
|
||||
|
|
@ -1417,18 +1605,18 @@ mod tests {
|
|||
if first_page(&build(fits, mid)).forks.len() == fits as usize { low = mid } else { high = mid }
|
||||
}
|
||||
let (in_page, over) = (build(fits, low), build(fits, low + 1));
|
||||
let held = page(in_page.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
let held = page(in_page.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
assert_eq!((held.0.forks.len(), held.0.next_cursor), (fits as usize, None));
|
||||
let size = bare(&DeskResponse::Forks { list: held.0, as_of: held.1 });
|
||||
// The wire grows in steps of up to three bytes per text byte, so "exact" is within one step.
|
||||
assert!(size <= DESK_PAGE_BUDGET && DESK_PAGE_BUDGET - size < 4, "{size} against {DESK_PAGE_BUDGET}");
|
||||
let (moved, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
let (moved, _) = page(over.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
assert_eq!(moved.forks.len(), fits as usize - 1, "one byte more and the oldest fork goes to the next page");
|
||||
// The page it would have been, composed from the single reads, is past the budget.
|
||||
let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() };
|
||||
let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() };
|
||||
assert!(bare(&DeskResponse::Forks { list: would_be, as_of: head(&over) }) > DESK_PAGE_BUDGET);
|
||||
assert_eq!(moved.next_cursor.map(ForkCursor::order), moved.forks.last().map(|entry| entry.card.order));
|
||||
let (rest, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50)));
|
||||
let (rest, _) = page(over.desk_forks(&account(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50)));
|
||||
assert_eq!(ids(&rest), [fork(1)]);
|
||||
}
|
||||
|
||||
|
|
@ -1437,7 +1625,7 @@ mod tests {
|
|||
let hash = hash_of(mind, &fork(n));
|
||||
let note = "a long reason, ".repeat(60);
|
||||
let mut added = 0;
|
||||
while bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= bytes {
|
||||
while bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= bytes {
|
||||
let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(¬e)) else { panic!("the note was not stored") };
|
||||
added += 1;
|
||||
assert!(added < 200, "the record does not grow");
|
||||
|
|
@ -1457,18 +1645,18 @@ mod tests {
|
|||
let mut mind = desk(3);
|
||||
grown_past(&mut mind, 2, DESK_PAGE_BUDGET);
|
||||
// The newest fork pages alone: the oversize one would bust the page beside it.
|
||||
let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
let (first, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
assert_eq!(ids(&first), [fork(3)]);
|
||||
assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2));
|
||||
let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50));
|
||||
let response = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50));
|
||||
let DeskResponse::NotStored(refusal) = response else { panic!("{response:?}") };
|
||||
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
|
||||
let order = 1;
|
||||
assert_eq!(refusal.error, fork_too_large(&fork(2), order).error);
|
||||
assert!(refusal.error.contains(&fork(2)) && refusal.error.contains("order 1"), "{refusal:?}");
|
||||
// The order the refusal names is the order of the fork it names; its cursor goes on past it.
|
||||
assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order);
|
||||
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50)));
|
||||
assert_eq!(entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order);
|
||||
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50)));
|
||||
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
|
||||
}
|
||||
|
||||
|
|
@ -1499,13 +1687,13 @@ mod tests {
|
|||
fn a_rank_is_stable_when_more_questions_are_admitted() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, (1..=5).map(question_n).collect()));
|
||||
let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
let (first, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2)));
|
||||
assert_eq!((orders(&first), first.next_cursor.map(ForkCursor::order)), (vec![4, 3], Some(3)));
|
||||
let held = first.next_cursor;
|
||||
let (before, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
|
||||
let (before, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, held, limit(50)));
|
||||
committed(admit(&mut mind, vec![question_n(6)]));
|
||||
committed(admit(&mut mind, vec![question_n(0)]));
|
||||
let (after, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50)));
|
||||
let (after, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, held, limit(50)));
|
||||
assert_eq!((after.forks, after.next_cursor), (before.forks, before.next_cursor), "the cursor from before resumes the same forks");
|
||||
let everything = all(&mind, 50);
|
||||
assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]);
|
||||
|
|
@ -1534,9 +1722,9 @@ mod tests {
|
|||
admit_unshowable(&mut mind, "Q2");
|
||||
committed(admit(&mut mind, vec![question_n(3)]));
|
||||
for who in [&LIST[..], &["x"][..], &["z"][..]] {
|
||||
let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(who), None, None, limit(50)));
|
||||
let (shown, _) = page(mind.desk_forks(&account(who[0]), &list(who), None, None, limit(50)));
|
||||
assert_eq!((ids(&shown), orders(&shown)), (vec![fork(3), fork(1)], vec![2, 0]), "{who:?}: rank 1 is the gap");
|
||||
assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}");
|
||||
assert_eq!(entry(mind.desk_fork(&account(who[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}");
|
||||
}
|
||||
committed(admit(&mut mind, vec![question_n(4)]));
|
||||
assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]);
|
||||
|
|
@ -1549,11 +1737,11 @@ mod tests {
|
|||
let mut mind = desk(2);
|
||||
for n in [1, 2] {
|
||||
grown_past(&mut mind, n, DESK_PAGE_BUDGET * 55 / 100);
|
||||
assert!(bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
|
||||
assert!(bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits");
|
||||
}
|
||||
let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
let (one, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)));
|
||||
assert_eq!((ids(&one), one.next_cursor.map(ForkCursor::order)), (vec![fork(2)], Some(1)));
|
||||
let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50)));
|
||||
let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50)));
|
||||
assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None));
|
||||
}
|
||||
|
||||
|
|
@ -1584,9 +1772,9 @@ mod tests {
|
|||
assert_eq!(after.len(), 70);
|
||||
assert_eq!(after.iter().map(|(_, order)| *order).collect::<std::collections::BTreeSet<_>>(), (0..70).collect());
|
||||
// The page boundary at c1: the page ending at order 1 names c1, and the next page is order 0 alone.
|
||||
let (upper, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2)));
|
||||
let (upper, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2)));
|
||||
assert_eq!((orders(&upper), upper.next_cursor.map(ForkCursor::order)), (vec![2, 1], Some(1)));
|
||||
let (last, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50)));
|
||||
let (last, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50)));
|
||||
assert_eq!((orders(&last), last.next_cursor), (vec![0], None));
|
||||
}
|
||||
|
||||
|
|
@ -1670,7 +1858,7 @@ mod tests {
|
|||
let mut mind = desk(3);
|
||||
close(&mut mind, &fork(1), "R9");
|
||||
close(&mut mind, &fork(2), "R2");
|
||||
let named = |n: u32| entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling;
|
||||
let named = |n: u32| entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling;
|
||||
assert_eq!(named(1).as_deref(), Some("eureka-state:ruling:R9"));
|
||||
assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2"));
|
||||
assert_eq!(named(3), None);
|
||||
|
|
@ -1721,6 +1909,9 @@ mod tests {
|
|||
}
|
||||
for (order, entry) in &held {
|
||||
let key = fork(*order as u32);
|
||||
if scenario.get("twin").is_some() {
|
||||
list_it(&mut mind, *order as u32);
|
||||
}
|
||||
let answers = entry["record"]["answers"].as_array().unwrap();
|
||||
let hash = hash_of(&mind, &key);
|
||||
if !answers.is_empty() {
|
||||
|
|
@ -1741,7 +1932,7 @@ mod tests {
|
|||
answered_at: "2026-09-16T11:00:00Z".into(),
|
||||
request_id: request_id(),
|
||||
};
|
||||
mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retraction, now());
|
||||
mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retraction, now());
|
||||
} else {
|
||||
answer(&mut mind, "y", &LIST, &key, &hash, None);
|
||||
}
|
||||
|
|
@ -1753,7 +1944,7 @@ mod tests {
|
|||
let (request, want) = (&step["request"], &step["response"]);
|
||||
let status: Option<ListStatus> = serde_json::from_value(request["status"].clone()).unwrap();
|
||||
let cursor: Option<ForkCursor> = serde_json::from_value(request["cursor"].clone()).unwrap();
|
||||
let (got, _) = page(mind.desk_forks(&s(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8)));
|
||||
let (got, _) = page(mind.desk_forks(&account(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8)));
|
||||
let name = &scenario["name"];
|
||||
assert_eq!((got.open_count, got.recorded_count), (want["open_count"].as_u64().unwrap(), want["recorded_count"].as_u64().unwrap()), "{name}");
|
||||
assert_eq!(serde_json::to_value(got.next_cursor).unwrap(), want["next_cursor"], "{name}");
|
||||
|
|
@ -1802,7 +1993,7 @@ mod tests {
|
|||
// Every third fork is closed; the batch's ranks follow its keys, so the closed ranks are read, not computed.
|
||||
let mut closed = std::collections::BTreeSet::new();
|
||||
for n in (1..=24).step_by(3) {
|
||||
closed.insert(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.order);
|
||||
closed.insert(entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0.card.order);
|
||||
close(&mut mind, &fork(n), &format!("R{n}"));
|
||||
}
|
||||
for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] {
|
||||
|
|
@ -1815,7 +2006,7 @@ mod tests {
|
|||
for size in 1..=24_u8 {
|
||||
let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0);
|
||||
loop {
|
||||
let (page, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size)));
|
||||
let (page, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), status, cursor, limit(size)));
|
||||
pages += 1;
|
||||
assert!(page.forks.len() <= size as usize && pages < 100);
|
||||
walked.extend(orders(&page));
|
||||
|
|
|
|||
|
|
@ -401,7 +401,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option<Lineage<'_>> {
|
|||
| D::Pulse(_)
|
||||
| D::Reply(_)
|
||||
| D::Thought(_)
|
||||
| D::DeskAnswer(_) => None,
|
||||
| D::DeskAnswer(_)
|
||||
| D::DeskListing(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,10 +456,11 @@ pub enum CitationRole {
|
|||
DayThrough,
|
||||
Fork,
|
||||
Answer,
|
||||
Listed,
|
||||
}
|
||||
|
||||
impl CitationRole {
|
||||
pub const ALL: [CitationRole; 26] = [
|
||||
pub const ALL: [CitationRole; 27] = [
|
||||
Self::RaisedIn,
|
||||
Self::Answers,
|
||||
Self::Rulings,
|
||||
|
|
@ -485,6 +487,7 @@ impl CitationRole {
|
|||
Self::DayThrough,
|
||||
Self::Fork,
|
||||
Self::Answer,
|
||||
Self::Listed,
|
||||
];
|
||||
|
||||
/// The wire spelling, equal to the referring field's name.
|
||||
|
|
@ -516,6 +519,7 @@ impl CitationRole {
|
|||
Self::DayThrough => "day_through",
|
||||
Self::Fork => "fork",
|
||||
Self::Answer => "answer",
|
||||
Self::Listed => "listed",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -543,6 +547,7 @@ impl CitationRole {
|
|||
Self::Run => K::Reply,
|
||||
Self::Reads => K::Thought,
|
||||
Self::Fork | Self::Answer => K::DeskAnswer,
|
||||
Self::Listed => K::DeskListing,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -552,7 +557,7 @@ impl CitationRole {
|
|||
pub fn target_kinds(self) -> Vec<PipelineKind> {
|
||||
use PipelineKind as K;
|
||||
match self {
|
||||
Self::Answers | Self::Questions | Self::Forks | Self::Fork => vec![K::Question],
|
||||
Self::Answers | Self::Questions | Self::Forks | Self::Fork | Self::Listed => vec![K::Question],
|
||||
Self::Rulings => vec![K::Ruling],
|
||||
Self::CutSpec => vec![K::CutSpec],
|
||||
Self::CutReport => vec![K::CutReport],
|
||||
|
|
@ -670,6 +675,7 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel
|
|||
DeskOutcome::Counted | DeskOutcome::Refused { .. } => {}
|
||||
}
|
||||
}
|
||||
D::DeskListing(listing) => edges.push((R::Listed, cited(K::Question, &listing.listed))),
|
||||
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {}
|
||||
}
|
||||
edges
|
||||
|
|
|
|||
|
|
@ -408,9 +408,9 @@ mod tests {
|
|||
fn desk_answer() -> DeskRequest {
|
||||
DeskRequest::Answer {
|
||||
mind: slug(INSTANCE),
|
||||
subject: crate::fixtures::s("user-7"),
|
||||
subject: crate::fixtures::acct("user-7"),
|
||||
authority: DeskAuthority::Member,
|
||||
deciders: vec![crate::fixtures::s("user-7")],
|
||||
deciders: vec![crate::fixtures::acct("user-7")],
|
||||
request: ForkAnswerRequest {
|
||||
schema_version: crate::desk_wire::RequestSchema::V0,
|
||||
fork_id: "eureka-state:question:Q1".into(),
|
||||
|
|
@ -425,7 +425,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn desk_requests() -> [(DeskRequest, &'static str); 3] {
|
||||
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]);
|
||||
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::acct("user-7"), vec![crate::fixtures::acct("user-7")]);
|
||||
[
|
||||
(desk_answer(), "Answer"),
|
||||
(DeskRequest::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone(), status: Some(crate::desk_wire::ListStatus::Open), cursor: crate::desk_wire::ForkCursor::new(7), limit: crate::desk_wire::ForkLimit::new(50).unwrap() }, "Forks"),
|
||||
|
|
@ -579,13 +579,13 @@ mod tests {
|
|||
fn a_maximal_desk_message_fits_the_frame() {
|
||||
let long = |n: usize| "x".repeat(n);
|
||||
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len();
|
||||
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::s(&format!("{at:0>200}"))).collect();
|
||||
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::acct(&format!("decider {at}"))).collect();
|
||||
let mut sized = vec![];
|
||||
for (request, _) in desk_requests() {
|
||||
let widest = match request {
|
||||
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
|
||||
mind,
|
||||
subject: crate::fixtures::s(&long(200)),
|
||||
subject: crate::fixtures::acct("the widest subject"),
|
||||
authority,
|
||||
deciders: deciders.clone(),
|
||||
request: ForkAnswerRequest {
|
||||
|
|
@ -596,10 +596,10 @@ mod tests {
|
|||
},
|
||||
},
|
||||
DeskRequest::Forks { mind, status, cursor, limit, .. } => {
|
||||
DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit }
|
||||
DeskRequest::Forks { mind, subject: crate::fixtures::acct("the widest subject"), deciders: deciders.clone(), status, cursor, limit }
|
||||
}
|
||||
DeskRequest::Fork { mind, .. } => {
|
||||
DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) }
|
||||
DeskRequest::Fork { mind, subject: crate::fixtures::acct("the widest subject"), deciders: deciders.clone(), fork_id: long(200) }
|
||||
}
|
||||
};
|
||||
let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
|
||||
|
|
@ -607,7 +607,8 @@ mod tests {
|
|||
sized.push(wire(&message));
|
||||
}
|
||||
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}");
|
||||
assert!(sized[0] > DESK_MAX_REQUEST_BYTES / 4, "the test builds a request that is actually large: {sized:?}");
|
||||
// 64 account ids of 36 bytes and a 4,000-byte note: the widest answer a request can carry.
|
||||
assert!(sized[0] > 4000 + 64 * 36, "the test builds a request that is actually large: {sized:?}");
|
||||
|
||||
let answer = ForkDeskAnswer {
|
||||
schema_version: crate::desk_wire::AnswerSchema::V0,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,21 @@ pub(crate) fn s(value: &str) -> Short {
|
|||
value.into()
|
||||
}
|
||||
|
||||
/// A deterministic Bifrost account id for a test's name for someone: a lowercase D-format UUID.
|
||||
pub(crate) fn account(name: &str) -> Short {
|
||||
// An id already in account form stands for itself.
|
||||
if name.len() == 36 && name.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f' | b'-')) {
|
||||
return Short(name.into());
|
||||
}
|
||||
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
|
||||
Short(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff))
|
||||
}
|
||||
|
||||
/// The same, as the stored type.
|
||||
pub(crate) fn acct(name: &str) -> eureka_pipeline::AccountId {
|
||||
eureka_pipeline::AccountId(account(name).0)
|
||||
}
|
||||
|
||||
pub(crate) fn l(value: &str) -> Label {
|
||||
value.into()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
|
|||
lines.push(&idea.idea.0);
|
||||
lines.push(&idea.tradeoffs.0);
|
||||
}
|
||||
// A desk answer is a person's words, not indexed.
|
||||
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) => {}
|
||||
// A desk answer is a person's words and a listing's reason is no retrieval text: neither is indexed.
|
||||
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) | D::DeskListing(_) => {}
|
||||
}
|
||||
lines.retain(|line| !line.is_empty());
|
||||
if lines.is_empty() {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ pub enum PipelineFacts {
|
|||
/// the one that counts for its fork at the read's snapshot, derived by `counted_answer` and never
|
||||
/// stored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`.
|
||||
DeskAnswer { fork: Short, sequence: u32, outcome: Short, counts: bool, authority: DeskAuthority },
|
||||
/// The question this listing lets members read.
|
||||
DeskListing { listed: Short },
|
||||
}
|
||||
|
||||
impl PipelineFacts {
|
||||
|
|
@ -306,6 +308,7 @@ impl PipelineFacts {
|
|||
counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()),
|
||||
authority: value.authority,
|
||||
},
|
||||
D::DeskListing(value) => Self::DeskListing { listed: value.listed.clone() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1849,7 +1852,7 @@ mod tests {
|
|||
|
||||
// Two desk answers on the open fork Q2: the second is a Revision of the first.
|
||||
let D::Question(shown) = question("Q2", &["A", "B"], "A") else { unreachable!() };
|
||||
let stamp = crate::desk::DeskStamp { answerer: s("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![s("x")] };
|
||||
let stamp = crate::desk::DeskStamp { answerer: crate::fixtures::account("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![crate::fixtures::account("x")] };
|
||||
let pick = |n: u32| crate::desk::DeskAnswerRequest {
|
||||
fork: s(&q2),
|
||||
options_hash: crate::desk::options_hash(&shown),
|
||||
|
|
@ -1862,6 +1865,10 @@ mod tests {
|
|||
assert!(matches!(mind.answer_desk(stamp.clone(), pick(n), now()), crate::desk::DeskAnswerOutcome::Stored { .. }));
|
||||
}
|
||||
|
||||
// The Mind lists Q2: one `listed` edge.
|
||||
committed(admit(&mut mind, vec![D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN), listed: s(&q2), reason: "Members may read why.".into() })]));
|
||||
let listing = id("desk_listing", "Q2");
|
||||
|
||||
let (n1, f1_closed, f2_closed) = (
|
||||
id("resolution", "question.Q1.n1"),
|
||||
id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())),
|
||||
|
|
@ -1910,8 +1917,9 @@ mod tests {
|
|||
(CitationRole::Fork, &answer_1, &q2),
|
||||
(CitationRole::Fork, &answer_2, &q2),
|
||||
(CitationRole::Answer, &answer_2, &answer_1),
|
||||
(CitationRole::Listed, &listing, &q2),
|
||||
];
|
||||
assert_eq!(CitationRole::ALL.len(), 26);
|
||||
assert_eq!(CitationRole::ALL.len(), 27);
|
||||
for role in CitationRole::ALL {
|
||||
let selection =
|
||||
Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() };
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ impl SelectionRow {
|
|||
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
|
||||
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
|
||||
D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![repo.identity()],
|
||||
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => {
|
||||
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) | D::DeskListing(_) => {
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
|
|
@ -466,7 +466,7 @@ mod tests {
|
|||
/// refused at the substrate's door.
|
||||
#[test]
|
||||
fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() {
|
||||
let table: [(K, &[&str]); 21] = [
|
||||
let table: [(K, &[&str]); 22] = [
|
||||
(K::Campaign, &["repo"]),
|
||||
(K::Target, &[]),
|
||||
(K::Question, &[]),
|
||||
|
|
@ -488,6 +488,7 @@ mod tests {
|
|||
(K::Reply, &[]),
|
||||
(K::Thought, &[]),
|
||||
(K::DeskAnswer, &[]),
|
||||
(K::DeskListing, &[]),
|
||||
];
|
||||
for (kind, extra) in table {
|
||||
let want = [&["root", "in_force", "faculty"][..], extra].concat();
|
||||
|
|
@ -503,7 +504,7 @@ mod tests {
|
|||
/// twenty-one.
|
||||
#[test]
|
||||
fn each_kind_declares_the_roles_it_carries_and_their_targets() {
|
||||
let table: [(K, &[&str]); 21] = [
|
||||
let table: [(K, &[&str]); 22] = [
|
||||
(K::Campaign, &[]),
|
||||
(K::Target, &[]),
|
||||
(K::Question, &["raised_in"]),
|
||||
|
|
@ -525,13 +526,14 @@ mod tests {
|
|||
(K::Reply, &["run"]),
|
||||
(K::Thought, &["reads"]),
|
||||
(K::DeskAnswer, &["fork", "answer"]),
|
||||
(K::DeskListing, &["listed"]),
|
||||
];
|
||||
for (kind, roles) in table {
|
||||
assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}");
|
||||
}
|
||||
assert_eq!(Vocabulary.target_leaves("answers"), vec![K::Question.type_id().to_string()]);
|
||||
assert_eq!(Vocabulary.target_leaves("rulings"), vec![K::Ruling.type_id().to_string()]);
|
||||
assert_eq!(Vocabulary.target_leaves("subject").len(), 21);
|
||||
assert_eq!(Vocabulary.target_leaves("subject").len(), 22);
|
||||
assert_eq!(
|
||||
sorted(Vocabulary.target_leaves("claims")),
|
||||
sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect())
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ use huginn_mind::desk_wire::{
|
|||
fork_too_large_read, AnswerSchema, CardSchema, CardStatus, DeskRequest, DeskResponse, EntrySchema, ForkCard, ForkCursor, ForkDeskAnswer, ForkEntry, ForkLimit,
|
||||
ForkList, ForkOption, ForkRecord, ForkRefusal, ForkRefusalCode, ForkRef, ListSchema, ListStatus, RecordSchema,
|
||||
};
|
||||
use huginn_mind::envelope::{OperationFailure, encode_desk_failure, encode_desk_request, encode_desk_response};
|
||||
use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug};
|
||||
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_request, encode_desk_response};
|
||||
use huginn_mind::eureka_pipeline::{AccountId, DeskAuthority, Short, Slug};
|
||||
|
||||
const RUNTIME: &str = "huginn-eureka";
|
||||
/// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format
|
||||
|
|
@ -42,11 +42,15 @@ fn s(text: &str) -> Short {
|
|||
Short(text.into())
|
||||
}
|
||||
|
||||
fn a(text: &str) -> AccountId {
|
||||
AccountId(text.into())
|
||||
}
|
||||
|
||||
fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequest {
|
||||
DeskRequest::Forks {
|
||||
mind: Slug("eureka".into()),
|
||||
subject: s(READER),
|
||||
deciders: vec![s(READER), s(OTHER_DECIDER)],
|
||||
subject: a(READER),
|
||||
deciders: vec![a(READER), a(OTHER_DECIDER)],
|
||||
status,
|
||||
cursor: cursor.and_then(ForkCursor::new),
|
||||
limit: ForkLimit::new(limit).unwrap(),
|
||||
|
|
@ -54,15 +58,15 @@ fn forks(status: Option<ListStatus>, cursor: Option<u64>, limit: u8) -> DeskRequ
|
|||
}
|
||||
|
||||
fn read() -> DeskRequest {
|
||||
DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(READER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() }
|
||||
DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(READER), deciders: vec![a(READER), a(OTHER_DECIDER)], fork_id: FORK.into() }
|
||||
}
|
||||
|
||||
/// The same reads from a subject the call's deciders do not name: a non-decider.
|
||||
fn forks_as_non_decider() -> DeskRequest {
|
||||
DeskRequest::Forks {
|
||||
mind: Slug("eureka".into()),
|
||||
subject: s(NON_DECIDER),
|
||||
deciders: vec![s(READER), s(OTHER_DECIDER)],
|
||||
subject: a(NON_DECIDER),
|
||||
deciders: vec![a(READER), a(OTHER_DECIDER)],
|
||||
status: Some(ListStatus::Open),
|
||||
cursor: None,
|
||||
limit: ForkLimit::new(50).unwrap(),
|
||||
|
|
@ -70,7 +74,7 @@ fn forks_as_non_decider() -> DeskRequest {
|
|||
}
|
||||
|
||||
fn read_as_non_decider() -> DeskRequest {
|
||||
DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(NON_DECIDER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() }
|
||||
DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(NON_DECIDER), deciders: vec![a(READER), a(OTHER_DECIDER)], fork_id: FORK.into() }
|
||||
}
|
||||
|
||||
fn list(open_count: u64, recorded_count: u64) -> DeskResponse {
|
||||
|
|
@ -196,6 +200,24 @@ fn hex(bytes: &[u8]) -> String {
|
|||
text
|
||||
}
|
||||
|
||||
/// A request message whose payload is `request` with one field changed to something the typed request
|
||||
/// cannot hold: what a client that skips the account grammar would send.
|
||||
fn malformed(id: &str, request: &DeskRequest, edit: impl Fn(&mut serde_json::Value)) -> CultNetMessage {
|
||||
use base64::Engine;
|
||||
let mut value = serde_json::to_value(request).unwrap();
|
||||
edit(&mut value);
|
||||
CultNetMessage::OperationRequest {
|
||||
message_id: id.into(),
|
||||
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
|
||||
operation: "fork".into(),
|
||||
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
|
||||
payload_encoding: "messagepack-base64".into(),
|
||||
payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()),
|
||||
source_runtime_id: None,
|
||||
target_runtime_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every fixture: its file stem and its bytes. A message gives `.frame.bin`, `.frame.hex` and,
|
||||
/// for an operation message, `.payload.msgpack` and `.payload.hex`; a bare payload gives the last two.
|
||||
fn fixtures() -> Vec<(String, Vec<u8>)> {
|
||||
|
|
@ -221,6 +243,15 @@ fn fixtures() -> Vec<(String, Vec<u8>)> {
|
|||
// raised_at nil: the question's first admission is unknown.
|
||||
message("read.response", encode_desk_response("m-4", "fork", &DeskResponse::Fork { entry: entry(true, None), as_of: 10190 }, RUNTIME).unwrap());
|
||||
message("read-non-decider.response", encode_desk_response("m-6", "fork", &DeskResponse::Fork { entry: entry(false, None), as_of: 10190 }, RUNTIME).unwrap());
|
||||
// A decider or subject that is no account id is refused at decode, naming the field.
|
||||
for (name, id, request) in [
|
||||
("read-mxid-decider", "m-7", malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into())),
|
||||
("read-email-subject", "m-8", malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into())),
|
||||
] {
|
||||
message(&format!("{name}.request"), request.clone());
|
||||
let failure = decode_desk_request(&request).expect_err("the door refuses it");
|
||||
message(&format!("{name}.failure.response"), encode_desk_failure(id, "fork", &failure, RUNTIME));
|
||||
}
|
||||
message("list-empty.response", encode_desk_response("m-1", "forks", &list(0, 0), RUNTIME).unwrap());
|
||||
message("list-counts.response", encode_desk_response("m-3", "forks", &list(3, 5), RUNTIME).unwrap());
|
||||
message(
|
||||
|
|
@ -290,8 +321,7 @@ fn account_id(text: &str) -> bool {
|
|||
}
|
||||
|
||||
/// Every account id the fixtures carry is a Bifrost account id, never a Matrix id, email or
|
||||
/// handle. The door does not refuse other shapes yet (`Short`, any 200 bytes); that refusal is owed
|
||||
/// by `cut-desk-feed-listing.r6`, and this pins the grammar it must enforce.
|
||||
/// handle, and the door refuses any other shape at decode, naming the field and never the value.
|
||||
#[test]
|
||||
fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
|
||||
for request in [forks(Some(ListStatus::Open), None, 50), read()] {
|
||||
|
|
@ -302,6 +332,14 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() {
|
|||
assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders");
|
||||
}
|
||||
assert!(record(true).answers.iter().all(|answer| account_id(answer.answerer.as_deref().unwrap())), "answerer");
|
||||
for (request, field, value) in [
|
||||
(malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into()), "deciders", "@heimdall:gamecult.org"),
|
||||
(malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into()), "subject", "meta@gamecult.org"),
|
||||
] {
|
||||
let failure = decode_desk_request(&request).expect_err("a request outside the grammar is refused");
|
||||
assert_eq!(failure.code, "payload-not-a-request");
|
||||
assert!(failure.message.contains(field) && !failure.message.contains(value), "{}", failure.message);
|
||||
}
|
||||
for shape in [
|
||||
"@heimdall:gamecult.org",
|
||||
"meta@gamecult.org",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ Each case has up to four files:
|
|||
Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The
|
||||
record's answers, notes and hash are synthetic.
|
||||
|
||||
**The listing gate is not live.** On `main` every showable question goes to every reader. Do not
|
||||
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing`
|
||||
lands (see `docs/desk-feed-door.md`).
|
||||
**The listing gate is live in Huginn.** A reader who is not on the call's deciders sees only forks the
|
||||
Mind lists (`docs/desk-feed-door.md`); `list-empty.response` is what such a reader gets from a Mind with
|
||||
nothing listed.
|
||||
|
||||
| case | direction | content |
|
||||
|---|---|---|
|
||||
|
|
@ -31,6 +31,10 @@ lands (see `docs/desk-feed-door.md`).
|
|||
| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes |
|
||||
| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil |
|
||||
| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id |
|
||||
| `read-mxid-decider.request` | client to Huginn | a `Fork` whose first decider is a Matrix id, so outside the account grammar; refused at decode |
|
||||
| `read-mxid-decider.failure.response` | Huginn to Bifrost | the operation failure for it: `payload-not-a-request`, naming `deciders`, never quoting the id |
|
||||
| `read-email-subject.request` | client to Huginn | a `Fork` whose subject is an email address; refused at decode |
|
||||
| `read-email-subject.failure.response` | Huginn to Bifrost | the operation failure for it, naming `subject` |
|
||||
| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` |
|
||||
| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` |
|
||||
| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) |
|
||||
|
|
|
|||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001738aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9606771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253466d615756735a43424762334a724c6e4e31596d706c5933513649476c75646d467361575174646d46736457553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9216669656c6420466f726b2e7375626a6563743a20696e76616c69642d76616c7565
|
||||
|
|
@ -0,0 +1 @@
|
|||
うcodeオpayload-not-a-requestァmessageル!field Fork.subject: invalid-value
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001ad87a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9fc6761524762334a72684b686b5a574e705a47567963354c5a4a44566d4d474d79596a466c4c5468684e4751744e474d7a596930355a54646d4c5446684d6d497a597a526b4e5755325a746b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e307357316c644746415a3246745a574e316248517562334a6eaf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
81a4466f726b84a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374b16d6574614067616d6563756c742e6f7267
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack
vendored
Normal 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
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001778aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164d9646771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253566d615756735a43424762334a724c6d526c59326c6b5a584a7a577a42644f694270626e5a6862476c6b4c585a686248566caf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9256669656c6420466f726b2e64656369646572735b305d3a20696e76616c69642d76616c7565
|
||||
|
|
@ -0,0 +1 @@
|
|||
うcodeオpayload-not-a-requestァmessageル%field Fork.deciders[0]: invalid-value
|
||||
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.bin
vendored
Normal file
BIN
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.bin
vendored
Normal file
Binary file not shown.
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
000001b687a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164da01046761524762334a72684b686b5a574e705a47567963354b325147686c6157316b595778734f6d64686257566a645778304c6d39795a396b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
81a4466f726b84a8646563696465727392b6406865696d64616c6c3a67616d6563756c742e6f7267d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666
|
||||
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack
vendored
Normal file
1
crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack
vendored
Normal 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
|
||||
|
|
@ -5,14 +5,13 @@ Status, 2026-11-11: the list and read wire described here is on `main` (`DeskReq
|
|||
and `desk_wire/reads.rs`; the socket is `crates/huginn-daemon/src/desk_door.rs`). No running daemon
|
||||
binds a desk socket yet (follow-up `eureka-substrate:follow_up:desk-door-ops-root-run`). The reader
|
||||
shapes are built: the card's `raised_at` and `decider_count`, the record's `answerer_count`, the
|
||||
nullable `answerer`, and the withholding of account ids from a non-decider (below). One part is
|
||||
planned and not built: the listing gate that decides which forks a reader sees (cut spec
|
||||
`cut-desk-feed-listing` in the `eureka-substrate` campaign).
|
||||
nullable `answerer`, and the withholding of account ids from a non-decider (below). The listing gate
|
||||
is built too: a reader who is not a decider sees a fork only while the Mind lists it (Listing, below).
|
||||
Both are `cut-desk-feed-raised-at` and `cut-desk-feed-listing` in the `eureka-substrate` campaign.
|
||||
|
||||
**Not live: the listing gate.** On `main` today every showable question is returned to every
|
||||
reader, whatever the call's `deciders` say. The default of not listed, described under Listing below,
|
||||
holds only once `cut-desk-feed-listing` lands. Until then, neither Bifrost's receiver nor the ops
|
||||
root-run (`desk-door-ops-root-run`) may go live on a Mind that holds private questions.
|
||||
**Operator note.** After deploy, a non-decider's Desk row for a Mind is empty until that Mind lists a
|
||||
fork. Say so in #minds before the feed goes live. Neither Bifrost's receiver nor the ops root-run
|
||||
(`desk-door-ops-root-run`) goes live on a Mind holding private questions before this is deployed.
|
||||
|
||||
This page is the wire contract for the consumer, Bifrost's `UnixSocketDeskDoor` (Bifrost PR #67,
|
||||
`docs/desk-feed-design.md`). The golden bytes are in `crates/huginn-mind/tests/fixtures/desk-feed/`.
|
||||
|
|
@ -99,10 +98,12 @@ no `authority`, and a payload that names it is refused whole.
|
|||
`DeskEndpoints.cs` with `accountId.Value.ToString("D")`, and `StartupConfigurationValidator.cs`
|
||||
refuses any decider not in that form). That is the same grammar Huginn names for `RequestId`
|
||||
(`uuid_text`). These ids are opaque random UUIDs. A Matrix id, an email, a display name or any other
|
||||
handle is never one of them. Bifrost enforces the grammar; Huginn's door does not yet: it reads these
|
||||
fields as `Short` (any text up to 200 bytes) and would accept `@x:server` or an email. A subject
|
||||
is stored as `answerer` on an answer, so a wrong value would enter the mind. Refusing every other
|
||||
shape at decode is owed by `cut-desk-feed-listing`. The test
|
||||
handle is never one of them. Bifrost enforces the grammar, and so does Huginn's door: `subject` and
|
||||
every `deciders` entry are read as `AccountId` (`eureka-pipeline`), and decode refuses any other shape as
|
||||
`payload-not-a-request`, naming the field and never quoting the value (fixtures
|
||||
`read-mxid-decider.request` and `read-email-subject.request`, each with its `.failure.response`). A
|
||||
subject is stored as `answerer` on an answer, and the stored `desk_answer.answerer` is an `AccountId` too,
|
||||
so no other shape can enter the mind. The test
|
||||
`every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use
|
||||
synthetic UUIDs.
|
||||
|
||||
|
|
@ -226,7 +227,7 @@ fixtures carry it (`list-open.response` a string, `read.response` nil).
|
|||
stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer
|
||||
ids.
|
||||
|
||||
## Listing: which forks a reader sees (Huginn's; NOT live, see the status above)
|
||||
## Listing: which forks a reader sees (Huginn's)
|
||||
|
||||
Huginn owns audience and visibility, at its door. The card has no audience key (CultLib), and
|
||||
Bifrost's feed applies no fork filter.
|
||||
|
|
@ -248,10 +249,10 @@ Bifrost's feed applies no fork filter.
|
|||
gate accepts this leak: no fork's text, id or count leaves the door. Ranking over the visible set
|
||||
only would make `order` depend on the reader and move whenever a fork is listed, which breaks
|
||||
CultLib's rule that an order never changes and breaks every held cursor.
|
||||
- **The listing record** is planned kind `desk_listing`, key `<campaign>:desk_listing:<question-label>`,
|
||||
- **The listing record** is kind `desk_listing` (`epiphany.pipeline.desk_listing.v2`), key `<campaign>:desk_listing:<question-label>`,
|
||||
fields `campaign`, `listed` (the question's id, in the same campaign) and `reason` (one line: why
|
||||
members may read it). It is
|
||||
admitted on the Mind's own door, so its receipt records who listed it and when. Unlisting is a
|
||||
admitted on the Mind's own Admit door only (the desk door refuses one), so its receipt records who listed it and when. Unlisting is a
|
||||
`Withdrawn` resolution on it, and relisting withdraws that resolution, which reopens it. Each
|
||||
flip is a document with its own receipt, so the history is `huginn history <listing id>`. No flag
|
||||
is stored anywhere else.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"$defs": {
|
||||
"AccountId": {
|
||||
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
|
||||
"maxLength": 36,
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
"type": "string"
|
||||
},
|
||||
"DeskAuthority": {
|
||||
"enum": [
|
||||
"Operator",
|
||||
|
|
@ -136,7 +142,7 @@
|
|||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -178,7 +178,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "DeskRequest",
|
||||
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `deciders`\nfeed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider\nand is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost\nstrips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
|
||||
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `deciders`\nfeed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider,\nsees every showable fork and is told the ids; any other reads only the forks the Mind lists\n(a `desk_listing` in force) and is told counts in the ids' place. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost\nstrips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"$ref": "#/$defs/ForkAnswerRequest"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
]
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
"deciders": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"maxItems": 64,
|
||||
"minItems": 1,
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
|
@ -140,6 +140,12 @@
|
|||
}
|
||||
],
|
||||
"$defs": {
|
||||
"AccountId": {
|
||||
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
|
||||
"type": "string",
|
||||
"maxLength": 36,
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"DeskAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -215,11 +221,6 @@
|
|||
"gamecult.fork.answer_request.v0"
|
||||
]
|
||||
},
|
||||
"Short": {
|
||||
"description": "Text of at most 200 UTF-8 bytes.",
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"Slug": {
|
||||
"description": "A campaign slug or key segment: label parts joined by `.`.",
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -1682,7 +1682,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
]
|
||||
},
|
||||
"PipelineRefusal": {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,12 @@
|
|||
}
|
||||
],
|
||||
"$defs": {
|
||||
"AccountId": {
|
||||
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
|
||||
"type": "string",
|
||||
"maxLength": 36,
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"AuthorityMap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1320,7 +1326,7 @@
|
|||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
|
|
@ -1390,6 +1396,26 @@
|
|||
"outcome"
|
||||
]
|
||||
},
|
||||
"PipelineDeskListing": {
|
||||
"description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"campaign": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"listed": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/$defs/Line"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"campaign",
|
||||
"listed",
|
||||
"reason"
|
||||
]
|
||||
},
|
||||
"PipelineDocument": {
|
||||
"description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.",
|
||||
"oneOf": [
|
||||
|
|
@ -1728,6 +1754,22 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_listing"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/$defs/PipelineDeskListing"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1995,7 +2037,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
]
|
||||
},
|
||||
"PipelineMemory": {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,12 @@
|
|||
}
|
||||
],
|
||||
"$defs": {
|
||||
"AccountId": {
|
||||
"description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.",
|
||||
"type": "string",
|
||||
"maxLength": 36,
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"AdmissionFacts": {
|
||||
"description": "What admission recorded about a document when it landed: the receipt that\nwrote it, the `now` that receipt was taken at, who asked, and the\nreceipt's ordinal, which is the read side's order and its snapshot\nposition.",
|
||||
"type": "object",
|
||||
|
|
@ -430,7 +436,8 @@
|
|||
"reads",
|
||||
"day_through",
|
||||
"fork",
|
||||
"answer"
|
||||
"answer",
|
||||
"listed"
|
||||
]
|
||||
},
|
||||
"ClaimOutcome": {
|
||||
|
|
@ -2870,7 +2877,7 @@
|
|||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/Short"
|
||||
"$ref": "#/$defs/AccountId"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
|
|
@ -2940,6 +2947,26 @@
|
|||
"outcome"
|
||||
]
|
||||
},
|
||||
"PipelineDeskListing": {
|
||||
"description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"campaign": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"listed": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/$defs/Line"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"campaign",
|
||||
"listed",
|
||||
"reason"
|
||||
]
|
||||
},
|
||||
"PipelineDocument": {
|
||||
"description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.",
|
||||
"oneOf": [
|
||||
|
|
@ -3278,6 +3305,22 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_listing"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/$defs/PipelineDeskListing"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -4251,6 +4294,31 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The question this listing lets members read.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_listing"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listed": {
|
||||
"$ref": "#/$defs/Short"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"listed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -4518,7 +4586,8 @@
|
|||
"Idea",
|
||||
"Reply",
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
"DeskAnswer",
|
||||
"DeskListing"
|
||||
]
|
||||
},
|
||||
"PipelineMemory": {
|
||||
|
|
|
|||
|
|
@ -243,6 +243,17 @@
|
|||
"title": "Epiphany Pipeline DeskAnswer v2",
|
||||
"path": "epiphany.pipeline.desk_answer.v2.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_listing.v2.schema.json",
|
||||
"kind": "document_payload",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "epiphany.pipeline.desk_listing.v2",
|
||||
"documentType": "epiphany.pipeline.desk_listing.v2",
|
||||
"title": "Epiphany Pipeline DeskListing v2",
|
||||
"path": "epiphany.pipeline.desk_listing.v2.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "huginn.mind_request.v1",
|
||||
"kind": "wire_message",
|
||||
|
|
|
|||
Loading…
Reference in a new issue