diff --git a/Cargo.lock b/Cargo.lock index a3cfb26..d887bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,6 +961,7 @@ dependencies = [ "serde_path_to_error", "sha2", "tempfile", + "unicode-normalization", ] [[package]] @@ -1988,6 +1989,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -2073,6 +2080,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index 916532e..3f8d54c 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -11,6 +11,7 @@ # a failing test pass: a diff here is a wire change and needs its own ruling. # cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed. # target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed. +# sample.desk_answer was inserted after the sample corpus when the desk_answer kind was added (ruling eureka-substrate:ruling:desk-answer-kind-tagged-outcome); no earlier line changed. sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135 @@ -31,6 +32,7 @@ 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 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 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 5b85395..8297c3e 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -272,6 +272,27 @@ fn symbol_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { within(128)(field, value) } +/// A lowercase D-format UUID: 8-4-4-4-12 lowercase hex digits, any version. +fn uuid_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { + let shaped = value.len() == 36 + && value.bytes().enumerate().all(|(at, byte)| match at { + 8 | 13 | 18 | 23 => byte == b'-', + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }); + if !shaped { + return Err(format_error(field, value)); + } + Ok(()) +} + +/// A person's words: 1 to 4000 UTF-8 bytes and not blank (`str::trim` leaves something). +fn words_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { + if value.trim().is_empty() { + return Err(format_error(field, value)); + } + within(4000)(field, value) +} + bounded_text! { /// Text of at most 200 UTF-8 bytes. Short = 200, |field, value| within(200)(field, value); @@ -290,6 +311,11 @@ bounded_text! { Symbol = 128, symbol_text; /// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`. Para = 4000, |field, value| within(4000)(field, value); + /// A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`). + #[schemars(extend("minLength" = 1))] + Words = 4000, words_text; + /// A client's retry token: a lowercase D-format UUID. + RequestId = 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 `.`. @@ -738,6 +764,11 @@ unit_enums! { RunOperator { Operator, Mind } RunTurn { PersonaTurn, SelfRun, Sleep } MemorySort { Asked, Decided, Committed, Learned, Did, Idea, Rapport } + // The role the verified account holds, an attribution copied from Bifrost's state (CultLib PR #72, + // fork-card.md). It confers nothing: whether an answerer may decide is the decider list's, never this. + DeskAuthority { Operator, Steward, Member } + // Why a desk answer was refused and stored: binding spec 1.3 steps 4-7 and R1. + DeskRefusal { StaleFork, NotADecider, ForkClosed, ChoiceInvalid, BadRetraction, AlreadyRetracted } } /// The slots of a Mind's runs: one live run of hers per instance and slot. A @@ -838,6 +869,25 @@ value_types! { structural_delta: StructuralDelta, landed_names: Vec[128], undone: Vec[32], promises: Vec[64], } + /// One answer the fork desk's receiver passed to the Mind, refused ones included. + /// `answerer` and `authority` (an attribution, never a decision) are stamped by the + /// Mind from the verified caller and are absent from the request type; `answered_at` + /// is the client's claim kept as written, display only: order is `sequence`, the + /// receiver's order. `received_at` is the Mind's admission time (UTC) and + /// `claim_in_future` is set when `answered_at` is more than five minutes after it. + /// `request_id` is the client's retry token and `body_sha256` the digest of the + /// request it came with: the pair that makes a retry the same answer, never a new + /// one; the key is `(answerer, request_id)`. A record holds no card: a replayed + /// `stale_fork` is shown the current card. `sequence` is per question and is the + /// last part of the key (the answer id). `choice`, `note` and (for a retraction) the + /// target are kept as sent whether the request was counted or refused, so a refused + /// `choice_invalid` keeps a choice that is no option label; which answer counts now is + /// derived by the reader, never stored here. + pub struct PipelineDeskAnswer { + campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option, note: Option, + answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex, + answerer: Short, authority: DeskAuthority, outcome: DeskOutcome, + } pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec[64] } pub struct PipelineFinding { campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence, @@ -1124,6 +1174,48 @@ impl Bounded for ResolutionOutcome { } } +/// The one tagged outcome of a desk answer. A retraction is a variant, so a +/// retraction without a target and a target without a retraction cannot be +/// stored. `of` and `retracts` name desk answers by answer id (the record key). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum DeskOutcome { + /// The answer that counts for its fork when it was admitted. + Counted, + /// A later passing answer, kept visible; `of` is the counted answer it followed. + Revision { of: Short }, + Retraction { retracts: Short }, + /// A request the desk would not count, kept with what it was sent with. `retracts` is the + /// target a refused retraction named, as sent and unchecked (it may name no answer at all); + /// it is `None` on a refused answer, and it is never a citation. + Refused { code: DeskRefusal, retracts: Option }, +} + +impl DeskOutcome { + /// The variant's spelling, for headers and listings. + pub fn name(&self) -> &'static str { + match self { + Self::Counted => "Counted", + Self::Revision { .. } => "Revision", + Self::Retraction { .. } => "Retraction", + Self::Refused { .. } => "Refused", + } + } +} + +impl Bounded for DeskOutcome { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + match self { + Self::Counted => Ok(()), + Self::Refused { retracts, .. } => retracts.as_ref().map_or(Ok(()), |target| target.validate(&format!("{field}.retracts"))), + Self::Revision { of: id } | Self::Retraction { retracts: id } => { + let field = format!("{field}.{}", if matches!(self, Self::Revision { .. }) { "of" } else { "retracts" }); + id.validate(&field)?; + pipeline_id(&field, &id.0, PipelineKind::DeskAnswer).map(|_| ()) + } + } + } +} + macro_rules! pipeline_kinds { ($($variant:ident($value:ident) => $document:ident, $name:literal, $type_id:tt, $schema:tt;)*) => { $( @@ -1283,6 +1375,8 @@ pipeline_kinds! { "epiphany.pipeline.reply.v2", "EpiphanyPipelineReplyDocument"; Thought(PipelineThought) => EpiphanyPipelineThoughtDocument, "thought", "epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument"; + DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer", + "epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument"; } /// Parses a full document id: the grammar read backwards. A key and an id are @@ -1393,6 +1487,8 @@ const _: () = assert!(64 + ":".len() + LONGEST_KIND_NAME + ":".len() + RESOLUTIO fn local_max(kind: PipelineKind) -> usize { match kind { PipelineKind::Resolution => RESOLUTION_LOCAL_MAX, + // `.n`: every question label is answerable. + PipelineKind::DeskAnswer => SUBJECT_LOCAL_MAX + SEQUENCE_PART_MAX, _ => SUBJECT_LOCAL_MAX, } } @@ -1502,6 +1598,11 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result { + let label = parent_local("desk_answer.fork", &value.fork.0, &value.campaign.0, PipelineKind::Question)?; + let sequence = format!("n{}", value.sequence); + (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &sequence])?) + } 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')?; @@ -1774,6 +1875,13 @@ mod tests { nearest_permille: vec![310, 870], chosen: 0, projector_ms: 4200, continuer_ms: 9100, prompt_version: "ghostlight=c43eb54; words=180".into(), }), format!("{INSTANCE}:thought:t1793000000")), + (D::DeskAnswer(PipelineDeskAnswer { + 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, + outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") }, + }), format!("{CAMPAIGN}:desk_answer:Q1.n1")), ] } @@ -3908,11 +4016,11 @@ 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(), 20, "twenty kinds"); + assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds"); assert_eq!( variants.iter().map(tag).collect::>(), PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::>(), - "the document schema lists exactly the twenty kinds, by name" + "the document schema lists exactly the twenty-one kinds, by name" ); let refusal_schema = serde_json::to_value(schemars::schema_for!(PipelineRefusal))?; @@ -4436,4 +4544,40 @@ mod tests { ); Ok(()) } + + fn desk_answer_sample() -> PipelineDeskAnswer { + let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() }; + answer + } + + #[test] + fn a_desk_answer_is_keyed_under_its_question_and_sequence() { + let mut answer = desk_answer_sample(); + let key = |answer: &PipelineDeskAnswer| pipeline_key(&PipelineDocument::DeskAnswer(answer.clone())); + assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:Q1.n1"))); + // Every 64-byte question label is answerable at the largest sequence. + let label = "q".repeat(64); + answer.fork = id("question", &label); + answer.sequence = u32::MAX; + answer.outcome = DeskOutcome::Counted; + assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:{label}.n4294967295"))); + // The fork must be a question of the same campaign. + answer.fork = Short(format!("other:question:{label}")); + assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. }))); + answer.fork = id("ruling", "Q1"); + assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. }))); + } + + #[test] + fn a_desk_outcome_names_desk_answers_by_id() { + let mut answer = desk_answer_sample(); + for bad in [id("question", "Q1"), Short("n1".into())] { + answer.outcome = DeskOutcome::Retraction { retracts: bad.clone() }; + assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}"); + answer.outcome = DeskOutcome::Revision { of: bad.clone() }; + assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}"); + } + answer.outcome = DeskOutcome::Retraction { retracts: id("desk_answer", "Q1.n2") }; + assert_eq!(PipelineDocument::DeskAnswer(answer).validate(), Ok(())); + } } diff --git a/crates/eureka-state/src/bin/huginn/admit_hold.rs b/crates/eureka-state/src/bin/huginn/admit_hold.rs index f48ed4c..b8228ae 100644 --- a/crates/eureka-state/src/bin/huginn/admit_hold.rs +++ b/crates/eureka-state/src/bin/huginn/admit_hold.rs @@ -49,7 +49,7 @@ pub fn check( } text.push_str(&format!("For {id}:\n")); for (_, view) in &found { - text.push_str(&render::header(&PipelineDocumentSummary::of(view))); + text.push_str(&render::header(&PipelineDocumentSummary::of(view, &|_| None))); text.push('\n'); } } diff --git a/crates/eureka-state/src/bin/huginn/schema.rs b/crates/eureka-state/src/bin/huginn/schema.rs index 8b5ab56..83b270b 100644 --- a/crates/eureka-state/src/bin/huginn/schema.rs +++ b/crates/eureka-state/src/bin/huginn/schema.rs @@ -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, + hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer, ); /// The schema named, or the names when none is: the exit code is 2 for a name diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 538f70a..655e760 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -1144,12 +1144,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(), 21, "{names:?}"); + assert_eq!(names.len(), 22, "{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]), ("reply", "thought", "selection")); + assert_eq!((names[18], names[19], names[20], names[21]), ("reply", "thought", "desk_answer", "selection")); for kind in PipelineKind::ALL { assert!(names.contains(&kind.name()), "{}", kind.name()); } diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 48e61ad..c5a25ff 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -28,6 +28,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_path_to_error = "0.1" sha2 = "0.10" +unicode-normalization = "0.1" [dev-dependencies] tempfile = "3" diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 96a2d6d..8f8a744 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -51,6 +51,26 @@ use crate::receipt::{self, CommitOutcome, IMPORT_EXTERNAL_MAX, ImportSource, Pip use crate::refusal::{MindRefusal, RunId}; use crate::store::MindStore; +/// The door a batch enters by. A desk answer is decided by `Mind::answer_desk` +/// before its document exists, from a stamp the caller cannot forge, so only the +/// desk's own call passes `Desk`; `admit`, `admit_prepared`, imports and +/// derivations pass `Admit` and may not write one (ruling +/// desk-second-peer-checked-door). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Door { + Admit, + 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 most envelopes one batch may carry. pub const BATCH_MAX: usize = 64; @@ -77,7 +97,7 @@ pub enum PipelineAdmissionOutcome { /// Keys a document through the leaf, then encodes it. A key the leaf cannot /// compose is the document's fault and is refused as a document; only the /// cache's encode fault is the organ's. -fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result { +pub(crate) fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result { pipeline_key(document).map_err(MindRefusal::Document)?; document.prepare(cache).map_err(unavailable) } @@ -108,14 +128,15 @@ impl Mind { envelopes: Vec, now: DateTime, ) -> PipelineAdmissionOutcome { - match self.admit_steps(instance, provenance, envelopes, now) { + match self.admit_steps(Door::Admit, instance, provenance, envelopes, now) { Ok(outcome) => outcome, Err(refusal) => PipelineAdmissionOutcome::Refused(refusal), } } - fn admit_steps( + pub(crate) fn admit_steps( &mut self, + door: Door, instance: &Slug, provenance: PipelineProvenance, envelopes: Vec, @@ -139,6 +160,7 @@ impl Mind { for envelope in envelopes { docs.push(stage(envelope, &mind)?)?; } + refuse_desk_answers_outside_desk(door, &docs)?; // A6 let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; if self.is_empty() && !carries_instance { @@ -162,6 +184,7 @@ impl Mind { docs.push(stage(envelope, &mind)?)?; } } + refuse_desk_answers_outside_desk(door, &docs)?; for staged in &docs.batch[originals..] { resolve(&docs, staged, &mind, external, &mut strong)?; } @@ -602,6 +625,8 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo } unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str())) } + // Decided in `answer_desk` before the document exists; no other door can stage one. + D::DeskAnswer(_) => Ok(()), D::Ruling(ruling) => { if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs new file mode 100644 index 0000000..e8c0fb7 --- /dev/null +++ b/crates/huginn-mind/src/desk.rs @@ -0,0 +1,1506 @@ +//! The fork desk's answers: the Mind's counting door. +//! +//! `Mind::answer_desk` is the one owner of whether a desk answer counts, its +//! sequence, its stored outcome and the stamping of `answerer` and `authority` +//! (binding spec /opt/commons/handoff/fork-desk-answer-binding-20261110.md, +//! 1.3 steps 3-8 and R1, R2, R4, and CultLib PR #72 `fork-card.md` for the +//! request id). It builds one `desk_answer` and commits it through +//! `admit_steps` with `Door::Desk`, the same commit primitive every other write +//! uses; no other door may write the kind. `answer_desk` and `DeskStamp` are +//! crate-private: only code inside this crate can stamp, so the verified door +//! that will call them (follow-up desk-answer-door) widens them in its own cut. +//! +//! The decider list is an input (`DeskStamp.deciders`, from the door); the Mind +//! never reads one. Whether an answerer decides is membership in that list and +//! nothing else: the stamped `authority` (Operator, Steward, Member) is an +//! attribution copied into the record and never decides. The request types carry +//! no `answerer` and no `authority`. `answered_at` is the client's claim: it is +//! displayed and flagged (`claim_in_future`), never compared for order, which is +//! the per-question sequence, the receiver's order. `received_at` is the Mind's. + +use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc}; +use eureka_pipeline::{ + DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal, + PipelineResolution, RequestId, ResolutionOutcome, Sha256Hex, Short, Slug, Words, +}; + +use crate::admission::{Door, PipelineAdmissionOutcome, prepare}; +use crate::docs::{Docs, kind_of_id}; +use crate::mind::Mind; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::rows::root_and_local; +use crate::store::MindStore; + +/// What the desk's receiver sends the Mind. `answered_at` is the person's +/// claim, RFC 3339 in UTC, kept as written. `request_id` is the client's retry +/// token (a lowercase UUID), required. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeskAnswerRequest { + pub fork: Short, + pub options_hash: Sha256Hex, + pub body: DeskBody, + pub answered_at: Short, + pub request_id: RequestId, +} + +/// An answer, a retraction of one, or one of the wire's illegal retraction forms. `choice` is +/// the person's text, any non-empty string: one that is no option label is a stored +/// `choice_invalid`, grammar or not (binding spec step 7). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeskBody { + Answer { choice: Option, note: Option }, + Retraction { retracts: Short }, + /// A retraction carrying an answer, naming no target, or a plain answer carrying + /// `retracts` (R1): what the door decodes those three forms to. Always stored as a + /// refused `bad_retraction` after steps 4 to 6; the record keeps every field as sent. + IllegalRetraction { choice: Option, note: Option, retracts: Option }, +} + +/// The door's verified context for one call: who answered, the role the account +/// holds (an attribution, stored on every answer) and the decider list the door +/// holds. Membership of `answerer` in `deciders` is rule 5's whole input; the +/// attribution never enters it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DeskStamp { + pub(crate) answerer: Short, + pub(crate) authority: DeskAuthority, + pub(crate) deciders: Vec, +} + +/// `Stored`: the answer id (the record key), its outcome and the commit's receipt. +/// `Replayed`: the same `(answerer, request_id)` and body came before; the stored +/// outcome comes back unchanged and nothing was written. (A record holds no card: +/// the read side shows the current card beside a `stale_fork`, replayed or not.) +/// `Refused`: nothing was stored (a shape fault, an unknown fork, a moved campaign, +/// a request id reused for another body, or a commit fault). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeskAnswerOutcome { + Stored { answer: Short, outcome: DeskOutcome, receipt_id: String }, + Replayed { answer: Short, outcome: DeskOutcome }, + Refused(MindRefusal), +} + +/// The hash of a fork as the desk shows it, which an answer must quote (rule 4): the +/// lowercase hex SHA-256 of the canonical bytes pinned by CultLib PR #72 +/// (`contracts/cultnet/governance/fork-card.md`, "Options hash"). The five members +/// `context`, `depends`, `options[{id, text}]`, `recommendation`, `title`, every string +/// NFC-normalised, serialised as RFC 8785 JCS. The struct declares its fields in JCS key +/// order, and `serde_json::to_string` writes no whitespace, escapes only `"`, `\` and the +/// controls below U+0020 (lowercase `\u00xx`, `\b \t \n \f \r` short forms) and +/// writes everything else raw, which is JCS for strings; the five members hold no number. +/// `depends` are the stored lines verbatim (`PipelineQuestion.depends` is prose lines). +/// This is the one owner: the counting rule calls it and nothing else computes the hash. +pub fn options_hash(question: &PipelineQuestion) -> Sha256Hex { + use sha2::{Digest, Sha256}; + use unicode_normalization::UnicodeNormalization; + + // Declared in JCS key order (UTF-16 code units: context < depends < options < recommendation < title). + #[derive(serde::Serialize)] + struct Shown { + context: String, + depends: Vec, + options: Vec, + recommendation: String, + title: String, + } + #[derive(serde::Serialize)] + struct ShownOption { + id: String, + text: String, + } + let nfc = |text: &str| text.nfc().collect::(); + let shown = Shown { + context: nfc(&question.question.0), + depends: question.depends.iter().map(|line| nfc(&line.0)).collect(), + options: question.options.iter().map(|option| ShownOption { id: nfc(&option.label.0), text: nfc(&option.text.0) }).collect(), + recommendation: nfc(&question.recommended.0), + title: nfc(&question.title.0), + }; + let canonical = serde_json::to_string(&shown).expect("strings serialise"); + Sha256Hex(Sha256::digest(canonical.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect()) +} + +/// Every desk answer of one fork, with its key. +fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator + 'a { + docs.of_kind(PipelineKind::DeskAnswer).filter_map(move |(key, document)| match document { + PipelineDocument::DeskAnswer(answer) if answer.fork.0 == fork => Some((key, answer)), + _ => None, + }) +} + +fn is_retracted(docs: &Docs<'_>, fork: &str, key: &str) -> bool { + answers_of(docs, fork).any(|(_, other)| matches!(&other.outcome, DeskOutcome::Retraction { retracts } if retracts.0 == key)) +} + +/// Whether a stored answer is a candidate to count under the fork's current card: +/// Counted or Revision, and written against the card's current options hash. +fn counts_under(answer: &PipelineDeskAnswer, current: &Sha256Hex) -> bool { + matches!(answer.outcome, DeskOutcome::Counted | DeskOutcome::Revision { .. }) && answer.options_hash == *current +} + +/// The answer that counts for `fork` now: the lowest-sequence answer whose stored +/// outcome is Counted or Revision under the card's current options hash and that no +/// Retraction names (R4; an answer under an older hash stops counting). Derived on +/// every call and never stored; retracting the counted answer promotes the next, +/// retracting another changes nothing. The one place "counts" is decided. +pub(crate) fn counted_answer(docs: &Docs<'_>, fork: &str) -> Option { + let Some(PipelineDocument::Question(question)) = docs.find(PipelineKind::Question, fork) else { return None }; + let current = options_hash(question); + answers_of(docs, fork) + .filter(|(key, answer)| counts_under(answer, ¤t) && !is_retracted(docs, fork, key)) + .min_by_key(|(_, answer)| answer.sequence) + .map(|(key, _)| key.to_string()) +} + +/// Rules 4-7 and R1 in the binding spec's order; the first failure is the code. +fn refusal_of(docs: &Docs<'_>, question: &PipelineQuestion, stamp: &DeskStamp, request: &DeskAnswerRequest) -> Option { + let fork = request.fork.0.as_str(); + let current = options_hash(question); + if request.options_hash != current { + return Some(DeskRefusal::StaleFork); + } + if !stamp.deciders.contains(&stamp.answerer) { + return Some(DeskRefusal::NotADecider); + } + if !docs.in_force(PipelineKind::Question, fork) { + return Some(DeskRefusal::ForkClosed); + } + match &request.body { + DeskBody::Answer { choice: Some(choice), .. } if !question.options.iter().any(|option| option.label.0 == choice.0) => { + Some(DeskRefusal::ChoiceInvalid) + } + DeskBody::Answer { .. } => None, + DeskBody::IllegalRetraction { .. } => Some(DeskRefusal::BadRetraction), + DeskBody::Retraction { retracts } => { + let target = answers_of(docs, fork).find(|(key, _)| *key == retracts.0); + if !target.is_some_and(|(_, answer)| counts_under(answer, ¤t)) { + return Some(DeskRefusal::BadRetraction); + } + is_retracted(docs, fork, &retracts.0).then_some(DeskRefusal::AlreadyRetracted) + } + } +} + +/// The one definition of "the same body": Huginn's own SHA-256 over the canonical body of the +/// request, which is every part of it but the id itself and so includes `answered_at` and, for a +/// retraction, the target. It is never read from the client. It decides a replay (equal), a +/// `request_id_conflict` (unequal under a bound id) and nothing else; no other code compares +/// bodies, a refused retraction's target included, which the stored `outcome` already carries. +/// +/// FROZEN: the digest form (field order, `serde_json` string of `Sent`, lowercase hex) is stored +/// in every answer and read back by retries years later. A change of form is a new version of the +/// stored field, never an edit here; `body_digest_is_pinned` fails on any edit. +fn body_digest(request: &DeskAnswerRequest) -> Sha256Hex { + use sha2::{Digest, Sha256}; + #[derive(serde::Serialize)] + struct Sent<'a> { + fork: &'a str, + options_hash: &'a str, + answered_at: &'a str, + choice: Option<&'a str>, + note: Option<&'a str>, + retracts: Option<&'a str>, + // Written only for the illegal form, so every other body keeps its stored digest. + #[serde(skip_serializing_if = "is_false")] + illegal_retraction: bool, + } + fn is_false(flag: &bool) -> bool { + !*flag + } + let (choice, note, retracts, illegal_retraction) = match &request.body { + DeskBody::Answer { choice, note } => (choice.as_ref().map(|c| c.0.as_str()), note.as_ref().map(|n| n.0.as_str()), None, false), + DeskBody::Retraction { retracts } => (None, None, Some(retracts.0.as_str()), false), + DeskBody::IllegalRetraction { choice, note, retracts } => { + (choice.as_ref().map(|c| c.0.as_str()), note.as_ref().map(|n| n.0.as_str()), retracts.as_ref().map(|r| r.0.as_str()), true) + } + }; + let sent = Sent { fork: &request.fork.0, options_hash: &request.options_hash.0, answered_at: &request.answered_at.0, choice, note, retracts, illegal_retraction }; + let bytes = serde_json::to_string(&sent).expect("strings serialise"); + Sha256Hex(Sha256::digest(bytes.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn shape_fault(refusal: PipelineRefusal) -> MindRefusal { + let field = match &refusal { + PipelineRefusal::FieldBound { field, .. } | PipelineRefusal::InvalidFormat { field, .. } | PipelineRefusal::DuplicateRepo { field, .. } => field.clone(), + PipelineRefusal::InvalidIdentity { .. } | PipelineRefusal::ForeignStore { .. } => "request".into(), + }; + MindRefusal::DeskRequestInvalid { field } +} + +impl Mind { + /// Decides one desk answer and, when the request is well formed and names a + /// held fork, stores it: counted, a revision, a retraction, or refused with its + /// code. A retry of a stored request is replayed, never stored again. See the + /// module header for the order and the stored/not-stored line. + #[cfg_attr(not(test), allow(dead_code))] // the verified door (follow-up desk-answer-door) is the live caller + pub(crate) fn answer_desk(&mut self, stamp: DeskStamp, request: DeskAnswerRequest, now: DateTime) -> DeskAnswerOutcome { + match self.desk_steps(stamp, request, now) { + Ok(outcome) => outcome, + Err(refusal) => DeskAnswerOutcome::Refused(refusal), + } + } + + fn desk_steps(&mut self, stamp: DeskStamp, request: DeskAnswerRequest, now: DateTime) -> Result { + let invalid = |field: &str| MindRefusal::DeskRequestInvalid { field: field.into() }; + // Shape. The fork must read as a question id; the probe document carries every other bounded field. + if kind_of_id(&request.fork.0) != Some(PipelineKind::Question) { + return Err(invalid("fork")); + } + let root = root_and_local(&request.fork.0).0.to_string(); + // answered_at is the client's claim and must be RFC 3339 in UTC (offset zero). + let claimed = match DateTime::parse_from_rfc3339(&request.answered_at.0) { + Ok(at) if at.offset().local_minus_utc() == 0 => at.with_timezone(&Utc), + _ => return Err(invalid("answered_at")), + }; + // A choice is any non-empty text of at most a Short (200 bytes), kept as sent: the record's field + // is a Short because a `choice_invalid` choice is no label. + let choice_shape = |choice: &Short| if choice.0.is_empty() || choice.0.len() > 200 { Err(invalid("desk_answer.choice")) } else { Ok(()) }; + let (choice, note, shape_outcome) = match &request.body { + DeskBody::Answer { choice, note } => { + if choice.is_none() && note.is_none() { + return Err(invalid("body")); + } + choice.as_ref().map(choice_shape).transpose()?; + (choice.clone(), note.clone(), DeskOutcome::Counted) + } + DeskBody::Retraction { retracts } => (None, None, DeskOutcome::Retraction { retracts: retracts.clone() }), + DeskBody::IllegalRetraction { choice, note, retracts } => { + choice.as_ref().map(choice_shape).transpose()?; + if retracts.as_ref().is_some_and(|target| target.0.len() > 200) { + return Err(invalid("desk_answer.outcome.retracts")); + } + (choice.clone(), note.clone(), DeskOutcome::Counted) + } + }; + let body_sha256 = body_digest(&request); + // received_at is the instant as stored, whole seconds, and claim_in_future is decided + // against that stored value, never the finer clock reading. + let received = now.trunc_subsecs(0); + let received_at = Short(received.to_rfc3339_opts(SecondsFormat::Secs, true)); + let claim_in_future = claimed > received + Duration::minutes(5); + let answer = |sequence: u32, outcome: DeskOutcome| PipelineDeskAnswer { + campaign: Slug(root.clone()), + fork: request.fork.clone(), + sequence, + options_hash: request.options_hash.clone(), + choice: choice.clone(), + note: note.clone(), + answered_at: request.answered_at.clone(), + received_at: received_at.clone(), + claim_in_future, + request_id: request.request_id.clone(), + body_sha256: body_sha256.clone(), + answerer: stamp.answerer.clone(), + authority: stamp.authority, + outcome, + }; + let image = self.image()?; + let docs = Docs::at(image, image.head); + let fork = request.fork.0.as_str(); + + // The retry lookup: after the cheap shape and the body digest, before the probe and before + // the fork is read, so a retry gets its stored outcome without re-validating and even if the + // card has since changed. A malformed request_id binds no record and meets the probe below. + // The key is (answerer, request_id). + let bound = docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document { + PipelineDocument::DeskAnswer(held) if held.answerer == stamp.answerer && held.request_id == request.request_id => Some((key, held)), + _ => None, + }); + if let Some((key, held)) = bound { + if held.body_sha256 != body_sha256 { + return Err(MindRefusal::DeskRequestIdConflict { request_id: request.request_id.0.clone() }); + } + return Ok(DeskAnswerOutcome::Replayed { answer: Short(key.to_string()), outcome: held.outcome.clone() }); + } + + let probe = PipelineDocument::DeskAnswer(answer(1, shape_outcome)); + probe.validate().map_err(shape_fault)?; + eureka_pipeline::pipeline_key(&probe).map_err(shape_fault)?; + + // Rules 3 and the moved campaign: nothing is stored. + let Some(PipelineDocument::Question(question)) = docs.find(PipelineKind::Question, fork) else { + return Err(MindRefusal::MissingReference { kind: PipelineKind::Question, id: fork.into() }); + }; + if let Some((moved, PipelineResolution { outcome: ResolutionOutcome::Moved { to }, .. })) = docs.moved_campaign(&root) { + return Err(MindRefusal::CampaignMoved { campaign: root_and_local(moved).0.into(), to: to.0.clone() }); + } + + // Rules 4-7, R1: stored, refused, with the first failing code. + let outcome = match refusal_of(&docs, question, &stamp, &request) { + Some(code) => DeskOutcome::Refused { + code, + retracts: match &request.body { + DeskBody::Retraction { retracts } => Some(retracts.clone()), + DeskBody::IllegalRetraction { retracts, .. } => retracts.clone(), + DeskBody::Answer { .. } => None, + }, + }, + None => match &request.body { + DeskBody::Retraction { retracts } => DeskOutcome::Retraction { retracts: retracts.clone() }, + DeskBody::IllegalRetraction { .. } => unreachable!("refusal_of refuses every illegal retraction"), + DeskBody::Answer { .. } => match counted_answer(&docs, fork) { + None => DeskOutcome::Counted, + Some(counted) => DeskOutcome::Revision { of: Short(counted) }, + }, + }, + }; + let sequence = answers_of(&docs, fork).map(|(_, held)| held.sequence).max().unwrap_or(0).checked_add(1).ok_or_else(|| invalid("sequence"))?; + let document = PipelineDocument::DeskAnswer(answer(sequence, outcome.clone())); + let envelope = prepare(&document, self.cache())?; + // The receipt names the operator only when the verified account is the operator's; a + // steward or member answerer is named as such. Attribution only: admission reads no faculty. + let provenance = PipelineProvenance { + faculty: match stamp.authority { + DeskAuthority::Operator => Faculty::Operator, + DeskAuthority::Steward => Faculty::Steward, + DeskAuthority::Member => Faculty::Member, + }, + agent: stamp.answerer.clone(), + session: "desk".into(), + tool: "huginn-desk".into(), + imported_from: None, + }; + let instance = self.instance().clone(); + match self.admit_steps(Door::Desk, &instance, provenance, vec![envelope], now)? { + PipelineAdmissionOutcome::Committed { receipt_id, writes, .. } => { + let answer = writes.into_iter().next().map(|written| written.id).ok_or_else(|| MindRefusal::Unavailable { detail: "a desk answer committed no write".into() })?; + Ok(DeskAnswerOutcome::Stored { answer, outcome, receipt_id }) + } + PipelineAdmissionOutcome::Refused(refusal) => Err(refusal), + PipelineAdmissionOutcome::AlreadyAdmitted { .. } | PipelineAdmissionOutcome::Conflict { .. } => { + Err(MindRefusal::Unavailable { detail: "a desk answer raced another write; ask again".into() }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eureka_pipeline::Label; + use crate::fixtures::*; + use crate::store::test_stores::MemoryStore; + use PipelineDocument as D; + use PipelineKind as K; + + const FORK: &str = "eureka-state:question:Q1"; + const FORK2: &str = "eureka-state:question:Q2"; + + fn question_with(label: &str, options: &[&str], recommended: &str) -> PipelineQuestion { + let D::Question(question) = question(label, options, recommended) else { unreachable!() }; + question + } + + fn desk() -> Mind { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question_n(1), question_n(2)])); + mind + } + + fn hash(label: &str) -> Sha256Hex { + options_hash(&question_with(label, &["A", "B"], "A")) + } + + /// The decider list the door holds in these 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() } + } + + fn decider(who: &str) -> DeskStamp { + stamp_as(who, DeskAuthority::Member, &LIST) + } + + /// An account on no decider list. + fn off() -> DeskStamp { + stamp_as("nobody", DeskAuthority::Member, &LIST) + } + + /// A fresh lowercase UUID per call: every request is its own unless a test reuses one. + fn fresh_id() -> RequestId { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + RequestId(format!("00000000-0000-4000-8000-{:012x}", NEXT.fetch_add(1, Ordering::Relaxed))) + } + + fn pick(fork: &str, label: &str, choice: &str, answered_at: &str) -> DeskAnswerRequest { + DeskAnswerRequest { + fork: s(fork), + options_hash: hash(label), + body: DeskBody::Answer { choice: Some(s(choice)), note: None }, + answered_at: s(answered_at), + request_id: fresh_id(), + } + } + + fn retract(fork: &str, label: &str, target: &str) -> DeskAnswerRequest { + DeskAnswerRequest { + fork: s(fork), + options_hash: hash(label), + body: DeskBody::Retraction { retracts: s(target) }, + answered_at: s("2026-11-10T12:00:00Z"), + request_id: fresh_id(), + } + } + + fn key(label: &str, n: u32) -> String { + format!("eureka-state:desk_answer:{label}.n{n}") + } + + fn stored(outcome: DeskAnswerOutcome) -> (String, DeskOutcome) { + match outcome { + DeskAnswerOutcome::Stored { answer, outcome, .. } | DeskAnswerOutcome::Replayed { answer, outcome, .. } => (answer.0, outcome), + other => panic!("expected a stored answer, got {other:?}"), + } + } + + fn counted(mind: &Mind, fork: &str) -> Option { + let image = mind.image().unwrap(); + counted_answer(&Docs::at(image, image.head), fork) + } + + fn held(mind: &Mind, answer: &str) -> PipelineDeskAnswer { + let image = mind.image().unwrap(); + match Docs::at(image, image.head).find(K::DeskAnswer, answer) { + Some(D::DeskAnswer(held)) => held.clone(), + other => panic!("no desk answer {answer}: {other:?}"), + } + } + + fn head(mind: &Mind) -> u64 { + mind.image().unwrap().head + } + + fn ask(mind: &mut Mind, stamp: &DeskStamp, request: DeskAnswerRequest) -> DeskAnswerOutcome { + mind.answer_desk(stamp.clone(), request, now()) + } + + #[test] + fn a_first_decider_answer_counts_and_a_later_one_is_a_revision() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + assert_eq!(stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))), (key("Q1", 1), DeskOutcome::Counted)); + assert_eq!( + stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "B", at))), + (key("Q1", 2), DeskOutcome::Revision { of: s(&key("Q1", 1)) }) + ); + assert_eq!( + stored(ask(&mut mind, &decider("y"), pick(FORK, "Q1", "B", at))), + (key("Q1", 3), DeskOutcome::Revision { of: s(&key("Q1", 1)) }) + ); + assert_eq!(counted(&mind, FORK), Some(key("Q1", 1))); + } + + #[test] + fn receiver_order_not_answered_at_decides_first() { + // The earlier arrival claims the later time; arrival still decides. + let mut mind = desk(); + let first = pick(FORK, "Q1", "A", "2026-11-10T13:00:00Z"); + let second = pick(FORK, "Q1", "B", "2026-11-10T11:00:00Z"); + stored(ask(&mut mind, &decider("x"), first)); + stored(ask(&mut mind, &decider("y"), second)); + assert_eq!(counted(&mind, FORK), Some(key("Q1", 1))); + assert_eq!(held(&mind, &key("Q1", 1)).choice, Some(s("A"))); + // Swap the arrival: the other answer counts. + let mut mind = desk(); + stored(ask(&mut mind, &decider("y"), pick(FORK, "Q1", "B", "2026-11-10T11:00:00Z"))); + stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", "2026-11-10T13:00:00Z"))); + assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).choice, Some(s("B"))); + // And by answerer: the lexically later answerer arriving first still counts. + 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")); + } + + #[test] + fn a_stale_hash_is_stored_refused_and_never_counted() { + let mut mind = desk(); + let mut request = pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"); + // The fork as another recommendation would render it: vector B against stored A. + request.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + let (answer, outcome) = stored(ask(&mut mind, &decider("x"), request)); + assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::StaleFork, retracts: None }); + assert_eq!(held(&mind, &answer).outcome, outcome); + assert_eq!(counted(&mind, FORK), None); + // A hash over the title alone is not the fork's hash. + let mut title_only = question_with("Q1", &["A", "B"], "A"); + title_only.options.clear(); + title_only.question = "".into(); + let mut request = pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"); + request.options_hash = options_hash(&title_only); + assert_eq!(stored(ask(&mut mind, &decider("x"), request)).1, DeskOutcome::Refused { code: DeskRefusal::StaleFork, retracts: None }); + } + + #[test] + fn an_answer_off_the_decider_list_is_refused() { + let mut mind = desk(); + let off = off(); + let (_, outcome) = stored(ask(&mut mind, &off, pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); + assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); + assert_eq!(counted(&mind, FORK), None); + } + + #[test] + fn an_answer_to_a_closed_fork_is_refused() { + let at = "2026-11-10T12:00:00Z"; + let mut withdrawn_fork = desk(); + committed(admit(&mut withdrawn_fork, vec![resolution(r(K::Question, FORK), withdrawn())])); + assert_eq!( + stored(ask(&mut withdrawn_fork, &decider("x"), pick(FORK, "Q1", "A", at))).1, + DeskOutcome::Refused { code: DeskRefusal::ForkClosed, retracts: None } + ); + let mut ruled_fork = desk(); + let mut ruled = ruling("RQ1"); + ruled.answers = Some(s(FORK)); + ruled.choice = Some(l("A")); + committed(admit(&mut ruled_fork, vec![D::Ruling(ruled)])); + assert_eq!( + stored(ask(&mut ruled_fork, &decider("x"), pick(FORK, "Q1", "A", at))).1, + DeskOutcome::Refused { code: DeskRefusal::ForkClosed, retracts: None } + ); + // The other fork stays open. + assert_eq!(stored(ask(&mut ruled_fork, &decider("x"), pick(FORK2, "Q2", "A", at))).1, DeskOutcome::Counted); + } + + #[test] + fn a_choice_outside_the_option_labels_is_refused() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + // Equal to an option's text, not its label. + let mut by_text = pick(FORK, "Q1", "A", at); + by_text.body = DeskBody::Answer { choice: Some(s("an-option")), note: None }; + assert_eq!(stored(ask(&mut mind, &decider("x"), by_text)).1, DeskOutcome::Refused { code: DeskRefusal::ChoiceInvalid, retracts: None }); + // A label of another question. + committed(admit(&mut mind, vec![question("Q3", &["Z", "Y"], "Z")])); + assert_eq!(stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "Z", at))).1, DeskOutcome::Refused { code: DeskRefusal::ChoiceInvalid, retracts: None }); + // A note alone is an answer. + let mut note_only = pick(FORK, "Q1", "A", at); + note_only.body = DeskBody::Answer { choice: None, note: Some("Neither; ask again.".into()) }; + assert_eq!(stored(ask(&mut mind, &decider("x"), note_only)).1, DeskOutcome::Counted); + } + + #[test] + fn refusals_follow_the_binding_step_order() { + let at = "2026-11-10T12:00:00Z"; + let off = off(); + let mut mind = desk(); + committed(admit(&mut mind, vec![resolution(r(K::Question, FORK), withdrawn())])); + let stale = |mut request: DeskAnswerRequest| { + request.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + request + }; + let bad_choice = |mut request: DeskAnswerRequest| { + request.body = DeskBody::Answer { choice: Some(s("Q")), note: None }; + request + }; + let code = |mind: &mut Mind, stamp: &DeskStamp, request| match stored(ask(mind, stamp, request)).1 { + DeskOutcome::Refused { code, .. } => code, + other => panic!("{other:?}"), + }; + // stale + off-list + closed + assert_eq!(code(&mut mind, &off, stale(pick(FORK, "Q1", "A", at))), DeskRefusal::StaleFork); + // off-list + closed + assert_eq!(code(&mut mind, &off, pick(FORK, "Q1", "A", at)), DeskRefusal::NotADecider); + // closed + bad choice + assert_eq!(code(&mut mind, &decider("x"), bad_choice(pick(FORK, "Q1", "A", at))), DeskRefusal::ForkClosed); + // off-list + bad choice, open fork + assert_eq!(code(&mut mind, &off, bad_choice(pick(FORK2, "Q2", "A", at))), DeskRefusal::NotADecider); + } + + #[test] + fn a_bad_retraction_is_stored_refused() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let (other_fork, _) = stored(ask(&mut mind, &decider("x"), pick(FORK2, "Q2", "A", at))); + let mut stale = pick(FORK, "Q1", "A", at); + stale.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + let (refused, _) = stored(ask(&mut mind, &decider("x"), stale)); + let code = |mind: &mut Mind, request| match stored(ask(mind, &decider("y"), request)).1 { + DeskOutcome::Refused { code, .. } => code, + other => panic!("{other:?}"), + }; + // The retraction's own record, once it exists, is not a target. + assert_eq!(code(&mut mind, retract(FORK, "Q1", &key("Q1", 99))), DeskRefusal::BadRetraction); + assert_eq!(code(&mut mind, retract(FORK, "Q1", &other_fork)), DeskRefusal::BadRetraction); + assert_eq!(code(&mut mind, retract(FORK, "Q1", &refused)), DeskRefusal::BadRetraction); + let mut stale_retraction = retract(FORK, "Q1", &first); + stale_retraction.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + assert_eq!(code(&mut mind, stale_retraction), DeskRefusal::StaleFork); + let (retraction, outcome) = stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &first))); + assert_eq!(outcome, DeskOutcome::Retraction { retracts: s(&first) }); + assert_eq!(code(&mut mind, retract(FORK, "Q1", &retraction)), DeskRefusal::BadRetraction); + assert_eq!(code(&mut mind, retract(FORK, "Q1", &first)), DeskRefusal::AlreadyRetracted); + } + + #[test] + fn any_decider_may_retract_and_a_non_decider_may_not() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let off = off(); + assert_eq!(stored(ask(&mut mind, &off, retract(FORK, "Q1", &first))).1, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: Some(s(&first)) }); + assert_eq!(counted(&mind, FORK), Some(first.clone())); + assert_eq!(stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &first))).1, DeskOutcome::Retraction { retracts: s(&first) }); + assert_eq!(counted(&mind, FORK), None); + } + + /// What a read at `as_of` shows of the fork's answers: (key, stored outcome name, counts now). + fn read_counts(mind: &Mind, as_of: u64, keys: &[String]) -> Vec<(String, String, bool)> { + let selection = cultnet_rs::Selection { keys: Some(keys.to_vec()), ..Default::default() }; + let page = mind.query_at(&selection, as_of).unwrap(); + let crate::query::PipelinePageItems::Headers(headers) = page.items else { panic!("headers") }; + headers + .into_iter() + .map(|header| match header.facts { + crate::query::PipelineFacts::DeskAnswer { outcome, counts, .. } => (header.id.id.0, outcome.0, counts), + other => panic!("{other:?}"), + }) + .collect() + } + + /// Which answer counts is derived at the read's snapshot, never the stored write-time word: n1 is + /// stored `Counted` forever, but once retracted the facts say it no longer counts and the promoted + /// n2 does; a read at an earlier snapshot shows the earlier counting. + #[test] + fn a_retraction_changes_which_answer_the_facts_say_counts() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let (n1, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let (n2, second) = stored(ask(&mut mind, &decider("y"), pick(FORK, "Q1", "B", at))); + assert_eq!(second, DeskOutcome::Revision { of: s(&n1) }); + let both = [n1.clone(), n2.clone()]; + let before = head(&mind); + assert_eq!(read_counts(&mind, before, &both), [(n1.clone(), "Counted".into(), true), (n2.clone(), "Revision".into(), false)]); + stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &n1))); + let after = head(&mind); + // The stored words are unchanged; the derived flag moved. + assert_eq!(read_counts(&mind, after, &both), [(n1.clone(), "Counted".into(), false), (n2.clone(), "Revision".into(), true)]); + assert_eq!(held(&mind, &n1).outcome, DeskOutcome::Counted); + // A read at the earlier snapshot shows the earlier counting. + assert_eq!(read_counts(&mind, before, &both), [(n1.clone(), "Counted".into(), true), (n2.clone(), "Revision".into(), false)]); + assert_eq!(counted(&mind, FORK), Some(n2)); + } + + /// Every refusal keeps what the request was sent with: the choice, the note and (for a retraction + /// form) the target, under every refusal code the desk stores. + #[test] + fn every_refusal_keeps_what_was_sent() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let kept = |mind: &mut Mind, stamp: &DeskStamp, request: DeskAnswerRequest| { + let (answer, outcome) = stored(ask(mind, stamp, request)); + let record = held(mind, &answer); + (outcome, record.choice, record.note) + }; + let with = |fork: &str, label: &str, body: DeskBody| DeskAnswerRequest { body, ..pick(fork, label, "A", at) }; + let answer = |choice: &str| DeskBody::Answer { choice: Some(s(choice)), note: Some("because".into()) }; + let code = |code: DeskRefusal, retracts: Option<&str>| DeskOutcome::Refused { code, retracts: retracts.map(s) }; + // stale_fork: an old card. + let mut stale = with(FORK, "Q1", answer("A")); + stale.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + assert_eq!(kept(&mut mind, &decider("x"), stale), (code(DeskRefusal::StaleFork, None), Some(s("A")), Some("because".into()))); + // not_a_decider, answer and retraction. + assert_eq!(kept(&mut mind, &off(), with(FORK, "Q1", answer("B"))), (code(DeskRefusal::NotADecider, None), Some(s("B")), Some("because".into()))); + assert_eq!(kept(&mut mind, &off(), retract(FORK, "Q1", &first)), (code(DeskRefusal::NotADecider, Some(&first)), None, None)); + // choice_invalid keeps the text that is no label. + assert_eq!(kept(&mut mind, &decider("x"), with(FORK, "Q1", answer("not a label"))), (code(DeskRefusal::ChoiceInvalid, None), Some(s("not a label")), Some("because".into()))); + // bad_retraction (no such answer) keeps the target; already_retracted keeps it too. + let missing = key("Q1", 99); + assert_eq!(kept(&mut mind, &decider("y"), retract(FORK, "Q1", &missing)), (code(DeskRefusal::BadRetraction, Some(&missing)), None, None)); + stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &first))); + assert_eq!(kept(&mut mind, &decider("z"), retract(FORK, "Q1", &first)), (code(DeskRefusal::AlreadyRetracted, Some(&first)), None, None)); + // fork_closed: a ruling closes the fork, then an answer and a retraction. + let mut ruled = ruling("RQ1"); + ruled.answers = Some(s(FORK)); + ruled.choice = Some(l("A")); + committed(admit(&mut mind, vec![D::Ruling(ruled)])); + assert_eq!(kept(&mut mind, &decider("x"), with(FORK, "Q1", answer("A"))), (code(DeskRefusal::ForkClosed, None), Some(s("A")), Some("because".into()))); + assert_eq!(kept(&mut mind, &decider("x"), retract(FORK, "Q1", &first)), (code(DeskRefusal::ForkClosed, Some(&first)), None, None)); + } + + #[test] + fn retracting_the_counted_answer_promotes_the_next() { + let at = "2026-11-10T12:00:00Z"; + let build = || { + let mut mind = desk(); + let (x, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let (y, _) = stored(ask(&mut mind, &decider("y"), pick(FORK, "Q1", "B", at))); + let (z, _) = stored(ask(&mut mind, &decider("z"), pick(FORK, "Q1", "A", at))); + (mind, x, y, z) + }; + let (mut mind, x, y, _z) = build(); + assert_eq!(counted(&mind, FORK), Some(x.clone())); + stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &x))); + assert_eq!(counted(&mind, FORK), Some(y.clone()), "the next, not the latest"); + // A new answer is a revision of the promoted one. + assert_eq!(stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))).1, DeskOutcome::Revision { of: s(&y) }); + // Retracting a non-counted answer changes nothing. + let (mut mind, x, _y, z) = build(); + stored(ask(&mut mind, &decider("x"), retract(FORK, "Q1", &z))); + assert_eq!(counted(&mind, FORK), Some(x)); + // Retracting everything leaves nothing counted, and the next answer counts again. + let (mut mind, x, y, z) = build(); + for target in [&x, &y, &z] { + stored(ask(&mut mind, &decider("x"), retract(FORK, "Q1", target))); + } + assert_eq!(counted(&mind, FORK), None); + assert_eq!(stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))).1, DeskOutcome::Counted); + } + + #[test] + fn refused_answers_are_stored_with_their_code() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let off = stamp_as("guest", DeskAuthority::Steward, &LIST); + let (answer, outcome) = stored(ask(&mut mind, &off, pick(FORK, "Q1", "A", at))); + 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))); + } + + #[test] + fn the_stamp_alone_sets_answerer_and_authority() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + 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)); + } + } + + #[test] + fn sequence_is_per_question() { + let mut mind = desk(); + let at = "2026-11-10T12:00:00Z"; + let long = "q".repeat(64); + committed(admit(&mut mind, vec![question(&long, &["A", "B"], "A")])); + let long_fork = format!("eureka-state:question:{long}"); + let mut keys = Vec::new(); + for (fork, label) in [(FORK, "Q1"), (FORK2, "Q2"), (FORK, "Q1"), (long_fork.as_str(), long.as_str()), (FORK2, "Q2")] { + keys.push(stored(ask(&mut mind, &decider("x"), pick(fork, label, "A", at))).0); + } + assert_eq!( + keys, + [key("Q1", 1), key("Q2", 1), key("Q1", 2), key(&long, 1), key("Q2", 2)] + ); + } + + #[test] + fn an_unknown_fork_or_shape_fault_stores_nothing() { + let mut mind = desk(); + let before = head(&mind); + let at = "2026-11-10T12:00:00Z"; + let unknown = ask(&mut mind, &decider("x"), pick("eureka-state:question:nope", "Q1", "A", at)); + assert!(matches!(unknown, DeskAnswerOutcome::Refused(MindRefusal::MissingReference { kind: K::Question, .. })), "{unknown:?}"); + let mut empty = pick(FORK, "Q1", "A", at); + empty.body = DeskBody::Answer { choice: None, note: None }; + let mut blank_note = pick(FORK, "Q1", "A", at); + blank_note.body = DeskBody::Answer { choice: None, note: Some(" \n\t ".into()) }; + let mut empty_note = pick(FORK, "Q1", "A", at); + empty_note.body = DeskBody::Answer { choice: Some(s("A")), note: Some("".into()) }; + let mut not_utc = pick(FORK, "Q1", "A", "2026-11-10T14:00:00+02:00"); + not_utc.answered_at = s("2026-11-10T14:00:00+02:00"); + let mut no_id = pick(FORK, "Q1", "A", at); + no_id.request_id = RequestId("".into()); + let mut upper_id = pick(FORK, "Q1", "A", at); + upper_id.request_id = RequestId("00000000-0000-4000-8000-00000000ABCD".into()); + let mut not_uuid = pick(FORK, "Q1", "A", at); + not_uuid.request_id = RequestId("req-1".into()); + let mut braced = pick(FORK, "Q1", "A", at); + braced.request_id = RequestId("{00000000-0000-4000-8000-00000000abcd}".into()); + let bad_time = pick(FORK, "Q1", "A", "yesterday"); + let not_a_question = pick("eureka-state:ruling:R1", "Q1", "A", at); + let bad_target = retract(FORK, "Q1", "n1"); + for (request, field) in [ + (empty, "body"), + (blank_note, "desk_answer.note"), + (empty_note, "desk_answer.note"), + (not_utc, "answered_at"), + (no_id, "desk_answer.request_id"), + (upper_id, "desk_answer.request_id"), + (not_uuid, "desk_answer.request_id"), + (braced, "desk_answer.request_id"), + (bad_time, "answered_at"), + (not_a_question, "fork"), + (bad_target, "desk_answer.outcome.retracts"), + ] { + assert_eq!( + ask(&mut mind, &decider("x"), request), + DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: field.into() }) + ); + } + assert_eq!(head(&mind), before); + } + + #[test] + fn a_moved_campaign_takes_no_answer() { + let mut mind = desk(); + let before = head(&mind); + committed(admit(&mut mind, vec![resolution(r(K::Campaign, "eureka-state:campaign:self"), ResolutionOutcome::Moved { to: slug("thought-cage") })])); + let moved = head(&mind); + assert!(moved > before); + let outcome = ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")); + assert!(matches!(outcome, DeskAnswerOutcome::Refused(MindRefusal::CampaignMoved { .. })), "{outcome:?}"); + assert_eq!(head(&mind), moved); + } + + #[test] + fn a_desk_answer_cannot_enter_through_admit_or_import() { + let mut mind = desk(); + let before = head(&mind); + let at = "2026-11-10T12:00:00Z"; + let (answer, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", at))); + let mut forged = held(&mind, &answer); + forged.sequence = 2; + forged.outcome = DeskOutcome::Counted; + let id = key("Q1", 2); + let doc = D::DeskAnswer(forged); + let after = head(&mind); + assert!(after > before); + assert_eq!(refusal(admit(&mut mind, vec![doc.clone()])), MindRefusal::DeskAnswerOutsideDesk { id: id.clone() }); + // The door is refused before references are read: a forged answer to a fork nobody holds is + // still the door's refusal. + let D::DeskAnswer(mut orphan) = doc.clone() else { unreachable!() }; + orphan.fork = s("eureka-state:question:nobody"); + orphan.sequence = 1; + let orphan_id = "eureka-state:desk_answer:nobody.n1".to_string(); + assert_eq!(refusal(admit(&mut mind, vec![D::DeskAnswer(orphan)])), MindRefusal::DeskAnswerOutsideDesk { id: orphan_id }); + // Beside a legitimate document, still refused whole. + assert_eq!(refusal(admit(&mut mind, vec![question_n(9), doc.clone()])), MindRefusal::DeskAnswerOutsideDesk { id: id.clone() }); + // An import carrying one. + let source = crate::receipt::ImportSource { + instance: slug(OTHER_INSTANCE), + receipt_id: s("mind-commit-0"), + ordinal: 1, + committed_at: s("2026-11-10T12:00:00Z"), + faculty: Faculty::Operator, + agent: s("claude"), + session: s("session-1"), + tool: s("admit"), + external: vec![], + }; + let mut provenance = provenance(Faculty::Hands); + provenance.imported_from = Some(source); + let envelope = crate::fixtures::prepare(&doc); + let outcome = mind.admit_prepared(&mind.instance().clone(), provenance, vec![envelope], now()); + assert_eq!(refusal(outcome), MindRefusal::DeskAnswerOutsideDesk { id }); + assert_eq!(head(&mind), after); + } + + #[test] + fn a_desk_answer_takes_no_resolution() { + let mut mind = desk(); + let (answer, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); + let before = head(&mind); + let by = r(K::Question, FORK2); + for outcome in [ + ResolutionOutcome::Superseded { by: vec![r(K::DeskAnswer, &answer)] }, + ResolutionOutcome::Answered { by: r(K::Ruling, "eureka-state:ruling:R1") }, + ResolutionOutcome::Fixed { commit: sha(), by: None }, + ResolutionOutcome::Deferred { to: by.clone() }, + ResolutionOutcome::Recorded { reason: "x".into() }, + withdrawn(), + ResolutionOutcome::Slept { line: "x".into(), day_through: None }, + ResolutionOutcome::Moved { to: slug("thought-cage") }, + ] { + let outcome = admit(&mut mind, vec![resolution(r(K::DeskAnswer, &answer), outcome)]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Refused(_)), "{outcome:?}"); + } + assert_eq!(head(&mind), before); + assert_eq!(counted(&mind, FORK), Some(answer)); + } + + /// Libby's vectors (CultLib PR #72 head 7aec4961, `fork-card-vectors.json`, `optionsHash`): the seven + /// of the binding file, A to G, and the eight she adds. Each is recomputed from its shown members. + fn vectors() -> Vec { + serde_json::from_str(include_str!("../vectors/fork-card-options-hash.json")).unwrap() + } + + fn shown(vector: &serde_json::Value) -> PipelineQuestion { + let text = |value: &serde_json::Value| -> String { value.as_str().unwrap().to_string() }; + let D::Question(mut question) = question("Q1", &["A", "B"], "A") else { unreachable!() }; + question.title = text(&vector["title"]).as_str().into(); + question.question = text(&vector["context"]).as_str().into(); + question.recommended = Label(text(&vector["recommendation"])); + question.options = vector["options"] + .as_array() + .unwrap() + .iter() + .map(|option| eureka_pipeline::QuestionOption { label: Label(text(&option["id"])), text: text(&option["text"]).as_str().into() }) + .collect(); + question.depends = vector["depends"].as_array().unwrap().iter().map(|line| text(line).as_str().into()).collect(); + question + } + + #[test] + fn options_hash_reproduces_binding_vectors() { + let vectors = vectors(); + assert_eq!(vectors.len(), 15); + for vector in &vectors { + let name = vector["name"].as_str().unwrap(); + assert_eq!(options_hash(&shown(vector)).0, vector["sha256"].as_str().unwrap(), "{name}"); + } + let by = |tag: &str| vectors.iter().find(|vector| vector["heimdall"] == tag).unwrap(); + let hash = |tag: &str| options_hash(&shown(by(tag))).0; + // The pinned values of the binding file, 1.5. + assert_eq!(hash("A"), "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"); + assert_eq!(hash("B"), "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"); + assert_eq!(hash("C"), "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"); + assert_eq!(hash("D"), "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"); + assert_eq!(hash("F"), "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"); + assert_eq!(hash("G"), "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"); + // E spells "Cafe" decomposed; NFC makes it D. + assert_eq!(hash("E"), hash("D")); + // Each shown member moves the hash. + let distinct = ["A", "B", "C", "D", "F", "G"].map(hash); + assert_eq!(distinct.iter().collect::>().len(), 6); + } + + #[test] + fn options_hash_needs_nfc_to_equal_across_spellings() { + // Without NFC the decomposed spelling hashes differently: this is what E pins. + use sha2::{Digest, Sha256}; + let vectors = vectors(); + let raw = |tag: &str| { + let vector = vectors.iter().find(|vector| vector["heimdall"] == tag).unwrap(); + let json = serde_json::json!({ + "context": vector["context"], "depends": vector["depends"], "options": vector["options"], + "recommendation": vector["recommendation"], "title": vector["title"], + }); + Sha256::digest(serde_json::to_string(&json).unwrap().as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect::() + }; + assert_ne!(raw("E"), raw("D")); + assert_eq!(raw("D"), options_hash(&shown(vectors.iter().find(|vector| vector["heimdall"] == "D").unwrap())).0); + } + + // ---- the fix batch on PR #29 ---- + + /// Writes `answer` the way only the desk door can, for a state the public path cannot reach + /// (an answer written under a card that has since changed). + fn plant(mind: &mut Mind, answer: PipelineDeskAnswer) { + let envelope = crate::fixtures::prepare(&D::DeskAnswer(answer)); + let instance = mind.instance().clone(); + let outcome = mind.admit_steps(Door::Desk, &instance, provenance(Faculty::Operator), vec![envelope], now()); + committed(outcome.unwrap()); + } + + fn at_offset(seconds: i64) -> String { + (now() + Duration::seconds(seconds)).to_rfc3339_opts(SecondsFormat::Secs, true) + } + + #[test] + fn the_decider_list_decides_and_the_attribution_does_not() { + let at = "2026-11-10T12:00:00Z"; + let mut mind = desk(); + // The operator's own account, off the list, is no decider. + let operator_off = stamp_as("op", DeskAuthority::Operator, &["x"]); + assert_eq!(stored(ask(&mut mind, &operator_off, pick(FORK, "Q1", "A", at))).1, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); + // A plain member on the list decides. + let member_on = stamp_as("m", DeskAuthority::Member, &["m"]); + assert_eq!(stored(ask(&mut mind, &member_on, pick(FORK, "Q1", "A", at))).1, DeskOutcome::Counted); + // An empty list admits no one. + let empty = stamp_as("m", DeskAuthority::Operator, &[]); + assert_eq!(stored(ask(&mut mind, &empty, pick(FORK2, "Q2", "A", at))).1, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); + } + + #[test] + fn the_receipt_names_the_operator_only_for_the_operator_account() { + let at = "2026-11-10T12:00:00Z"; + let mut mind = desk(); + for (authority, faculty) in [(DeskAuthority::Operator, Faculty::Operator), (DeskAuthority::Steward, Faculty::Steward), (DeskAuthority::Member, Faculty::Member)] { + let outcome = ask(&mut mind, &stamp_as("x", authority, &LIST), pick(FORK, "Q1", "A", at)); + 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")); + } + } + + #[test] + fn answers_under_an_older_card_neither_count_nor_can_be_retracted() { + let mut mind = desk(); + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); + let mut old = held(&mind, &first); + old.sequence = 2; + old.options_hash = Sha256Hex("e".repeat(64)); + old.request_id = fresh_id(); + old.outcome = DeskOutcome::Counted; + plant(&mut mind, old); + // Retract the current answer, so only the older-card one remains. + stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &first))); + assert_eq!(counted(&mind, FORK), None, "an answer under an older hash does not count"); + let older = key("Q1", 2); + // R1: a retraction names an answer under the current card only. + assert_eq!(stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &older))).1, DeskOutcome::Refused { code: DeskRefusal::BadRetraction, retracts: Some(s(&older)) }); + // The next answer is Counted, not a Revision of the older one. + let outcome = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "B", "2026-11-10T12:00:00Z"))).1; + assert_eq!(outcome, DeskOutcome::Counted); + } + + #[test] + fn received_at_is_the_minds_and_a_claim_over_five_minutes_ahead_is_flagged() { + let mut mind = desk(); + for (offset, flagged) in [(-86_400, false), (0, false), (300, false), (301, true)] { + let claim = at_offset(offset); + let (answer, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", &claim))); + let record = held(&mind, &answer); + assert_eq!((record.claim_in_future, record.answered_at, record.received_at), (flagged, s(&claim), s("2026-09-16T12:00:00Z")), "{offset}"); + } + } + + #[test] + fn a_note_is_one_to_four_thousand_bytes_and_not_blank() { + let at = "2026-11-10T12:00:00Z"; + let with_note = |mind: &mut Mind, choice: Option<&str>, note: String| { + let mut request = pick(FORK, "Q1", "A", at); + request.body = DeskBody::Answer { choice: choice.map(s), note: Some(Words(note)) }; + ask(mind, &decider("x"), request) + }; + let invalid = DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: "desk_answer.note".into() }); + let mut mind = desk(); + let before = head(&mind); + for note in ["".to_string(), " ".into(), "\u{3000}\n".into(), "x".repeat(4001), "\u{1F600}".repeat(1001), "e\u{301}".repeat(1334)] { + // With a choice or without: the note alone sinks the body, and nothing is stored. + assert_eq!(with_note(&mut mind, None, note.clone()), invalid, "{} bytes", note.len()); + assert_eq!(with_note(&mut mind, Some("A"), note.clone()), invalid, "{} bytes", note.len()); + } + assert_eq!(head(&mind), before); + for note in ["x".repeat(4000), "\u{1F600}".repeat(1000), format!("{}x", "e\u{301}".repeat(1333)), " a ".to_string()] { + assert!(matches!(stored(with_note(&mut mind, None, note)).1, DeskOutcome::Counted | DeskOutcome::Revision { .. })); + } + } + + #[test] + fn a_retraction_after_a_ruling_closed_the_fork_is_fork_closed() { + // Binding V10a: a ruling that answers the fork closes it; the retraction meets step 6. + let mut mind = desk(); + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); + let mut ruled = ruling("RQ1"); + ruled.answers = Some(s(FORK)); + ruled.choice = Some(l("A")); + committed(admit(&mut mind, vec![D::Ruling(ruled)])); + let (retraction, outcome) = stored(ask(&mut mind, &decider("y"), retract(FORK, "Q1", &first))); + assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::ForkClosed, retracts: Some(s(&first)) }); + assert_eq!(held(&mind, &retraction).outcome, outcome); + assert_eq!(counted(&mind, FORK), Some(first), "the answer still stands"); + } + + /// Two spellings of "café" hash alike in every member that may carry one. + #[test] + fn options_hash_normalises_context_option_text_and_depends() { + let hash_with = |set: &dyn Fn(&mut PipelineQuestion, &str), spelling: &str| { + let mut question = question_with("Q1", &["A", "B"], "A"); + set(&mut question, spelling); + options_hash(&question) + }; + type Setter = fn(&mut PipelineQuestion, &str); + let members: [(&str, Setter); 4] = [ + ("title", |q, s| q.title = s.into()), + ("context", |q, s| q.question = s.into()), + ("option text", |q, s| q.options[1].text = s.into()), + ("depends", |q, s| q.depends = vec![s.into()]), + ]; + for (member, set) in members { + let (composed, decomposed) = ("caf\u{e9} \u{2603}", "cafe\u{301} \u{2603}"); + assert_eq!(hash_with(&set, composed), hash_with(&set, decomposed), "{member}"); + assert_ne!(hash_with(&set, composed), hash_with(&set, "cafe \u{2603}"), "{member} still moves the hash"); + } + } + + fn replayed(outcome: DeskAnswerOutcome) -> (String, DeskOutcome) { + match outcome { + DeskAnswerOutcome::Replayed { answer, outcome } => (answer.0, outcome), + other => panic!("expected a replay, got {other:?}"), + } + } + + fn with_id(mut request: DeskAnswerRequest, id: &str) -> DeskAnswerRequest { + request.request_id = RequestId(id.into()); + request + } + + const R1: &str = "6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13"; + const R2: &str = "6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f14"; + const R3: &str = "6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f15"; + const AT: &str = "2026-11-10T12:00:00Z"; + + /// Binding V12 (Q1): a retry returns the stored outcome, Refused included, and writes nothing. + #[test] + fn q1_v12_a_retry_replays_the_stored_outcome_and_adds_nothing() { + let mut mind = desk(); + let request = with_id(pick(FORK, "Q1", "A", AT), R1); + assert_eq!(stored(ask(&mut mind, &decider("x"), request.clone())), (key("Q1", 1), DeskOutcome::Counted)); + let after_first = head(&mind); + assert_eq!(replayed(ask(&mut mind, &decider("x"), request)), (key("Q1", 1), DeskOutcome::Counted)); + assert_eq!(head(&mind), after_first, "no n2 and no Revision"); + // A stale-hash request replays as the same Refused stale_fork. + let mut stale = with_id(pick(FORK, "Q1", "A", AT), R2); + stale.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + let first = stored(ask(&mut mind, &decider("x"), stale.clone())); + assert_eq!(first.1, DeskOutcome::Refused { code: DeskRefusal::StaleFork, retracts: None }); + let after_stale = head(&mind); + assert_eq!(replayed(ask(&mut mind, &decider("x"), stale)), first); + // A Revision replays as the Revision it was, not as a new one. + let revision = with_id(pick(FORK, "Q1", "B", AT), R3); + let (n, outcome) = stored(ask(&mut mind, &decider("x"), revision.clone())); + assert_eq!(outcome, DeskOutcome::Revision { of: s(&key("Q1", 1)) }); + let after_revision = head(&mind); + assert!(after_revision > after_stale); + assert_eq!(replayed(ask(&mut mind, &decider("x"), revision)), (n, outcome)); + assert_eq!(head(&mind), after_revision); + // A retraction replays too. + let retraction = with_id(retract(FORK, "Q1", &key("Q1", 1)), "6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f16"); + let (r, outcome) = stored(ask(&mut mind, &decider("y"), retraction.clone())); + assert_eq!(replayed(ask(&mut mind, &decider("y"), retraction)), (r, outcome)); + } + + /// V12's lookup precedes the fork lookup: a retry gets its stored outcome after the campaign moved. + #[test] + fn q1_the_lookup_comes_before_the_fork_is_read() { + let mut mind = desk(); + let request = with_id(pick(FORK, "Q1", "A", AT), R1); + stored(ask(&mut mind, &decider("x"), request.clone())); + committed(admit(&mut mind, vec![resolution(r(K::Campaign, "eureka-state:campaign:self"), ResolutionOutcome::Moved { to: slug("thought-cage") })])); + assert_eq!(replayed(ask(&mut mind, &decider("x"), request)), (key("Q1", 1), DeskOutcome::Counted)); + } + + /// The lookup precedes the probe: a bound id replays its stored outcome without the probe's + /// validation. The stored answerer is rewritten past a Short, which no door can store but a + /// fresh probe refuses; a retry of it must still replay, and an unbound id must meet the probe. + #[test] + fn q1_the_lookup_comes_before_the_probe_validates() { + let long = "w".repeat(201); + let request = with_id(pick(FORK, "Q1", "A", AT), R1); + let mut first = desk(); + stored(ask(&mut first, &decider("x"), request.clone())); + let store = MemoryStore::new(); + first.store().rows().into_iter().for_each(|row| store.plant(row)); + let cache = crate::mind::schema_cache().unwrap(); + 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); + store.plant(PipelineDocument::DeskAnswer(held).prepare(&cache).unwrap()); + let mut mind = opened(store, INSTANCE); + let stamp = stamp_as(&long, 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); + let unbound = with_id(pick(FORK, "Q1", "A", AT), R2); + assert_eq!( + ask(&mut mind, &stamp, unbound), + DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: "desk_answer.answerer".into() }) + ); + } + + /// V13 (Q2): the same id with any other part of the body is request_id_conflict and the store is unchanged. + #[test] + fn q2_v13_the_same_id_with_another_body_conflicts_and_stores_nothing() { + let mut mind = desk(); + let sent = with_id(pick(FORK, "Q1", "A", AT), R1); + stored(ask(&mut mind, &decider("x"), sent.clone())); + let before = head(&mind); + let conflict = DeskAnswerOutcome::Refused(MindRefusal::DeskRequestIdConflict { request_id: R1.into() }); + let mut other_choice = sent.clone(); + other_choice.body = DeskBody::Answer { choice: Some(s("B")), note: None }; + let mut only_the_time = sent.clone(); + only_the_time.answered_at = s("2026-11-10T12:00:01Z"); + let mut with_note = sent.clone(); + with_note.body = DeskBody::Answer { choice: Some(s("A")), note: Some("Also this.".into()) }; + let mut other_fork = with_id(pick(FORK2, "Q2", "A", AT), R1); + other_fork.answered_at = s(AT); + let mut other_hash = sent.clone(); + other_hash.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + let mut as_retraction = sent.clone(); + as_retraction.body = DeskBody::Retraction { retracts: s(&key("Q1", 1)) }; + for request in [other_choice, only_the_time, with_note, other_fork, other_hash, as_retraction] { + assert_eq!(ask(&mut mind, &decider("x"), request), conflict); + assert_eq!(head(&mind), before); + } + // A retraction's id conflicts with another target. + let retraction = with_id(retract(FORK, "Q1", &key("Q1", 1)), R2); + stored(ask(&mut mind, &decider("y"), retraction.clone())); + let mut other_target = retraction; + other_target.body = DeskBody::Retraction { retracts: s(&key("Q1", 2)) }; + let settled = head(&mind); + assert_eq!(ask(&mut mind, &decider("y"), other_target), DeskAnswerOutcome::Refused(MindRefusal::DeskRequestIdConflict { request_id: R2.into() })); + assert_eq!(head(&mind), settled); + } + + /// V14 (Q3): the key is (answerer, request_id); an identical body from another account is its own request. + #[test] + fn q3_v14_another_account_with_the_same_id_and_body_is_its_own_request() { + let mut mind = desk(); + let sent = with_id(pick(FORK, "Q1", "A", AT), R1); + 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")); + // 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)); + // x's id never reveals x's refusal to y: x off the list is refused, y with the identical body is Counted. + let mut fresh = desk(); + let sent = with_id(pick(FORK, "Q1", "A", AT), R2); + assert_eq!(stored(ask(&mut fresh, &off(), sent.clone())).1, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); + assert_eq!(stored(ask(&mut fresh, &decider("y"), sent)), (key("Q1", 2), DeskOutcome::Counted)); + } + + /// V15 (Q4), the Huginn half: a request Huginn never stored is a fresh answer on retry. + #[test] + fn q4_v15_a_request_never_stored_is_answered_fresh_on_retry() { + let mut mind = desk(); + let request = with_id(pick(FORK, "Q1", "A", AT), R1); + // The first attempt died before Huginn: nothing is bound, so the retry is Counted. + assert_eq!(stored(ask(&mut mind, &decider("x"), request)), (key("Q1", 1), DeskOutcome::Counted)); + } + + /// V17 (Q6): a refusal that stores nothing binds no id. + #[test] + fn q6_v17_a_body_that_stored_nothing_leaves_its_id_free() { + let mut mind = desk(); + let mut blank = with_id(pick(FORK, "Q1", "A", AT), R2); + blank.body = DeskBody::Answer { choice: None, note: Some(" ".into()) }; + assert!(matches!(ask(&mut mind, &decider("x"), blank), DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { .. }))); + assert_eq!(stored(ask(&mut mind, &decider("x"), with_id(pick(FORK, "Q1", "A", AT), R2))).1, DeskOutcome::Counted); + let unknown = with_id(pick("eureka-state:question:nope", "Q1", "A", AT), R3); + assert!(matches!(ask(&mut mind, &decider("x"), unknown), DeskAnswerOutcome::Refused(MindRefusal::MissingReference { .. }))); + assert!(matches!(stored(ask(&mut mind, &decider("x"), with_id(pick(FORK2, "Q2", "A", AT), R3))).1, DeskOutcome::Counted)); + } + + /// V18: an id that is not a lowercase D-format UUID is refused (also in the shape table above). + #[test] + fn v18_request_ids_must_be_lowercase_d_format_uuids() { + for bad in ["", "retry-1", "6F1C0A52-3B7E-4D1A-9C58-0E2D7A4B9F13", "6f1c0a523b7e4d1a9c580e2d7a4b9f13", "{6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13}", "6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f1g", " 6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13"] { + let mut mind = desk(); + let before = head(&mind); + assert_eq!( + ask(&mut mind, &decider("x"), with_id(pick(FORK, "Q1", "A", AT), bad)), + DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: "desk_answer.request_id".into() }), + "{bad:?}" + ); + assert_eq!(head(&mind), before); + } + } + + // ---- fix batch 3 ---- + + /// Faculty is attribution: a batch admitted under any faculty, Member and Steward included, + /// gets the outcome Hands gets, and no faculty opens the desk-answer door. + #[test] + fn member_and_steward_confer_no_admission_right() { + let all = [Faculty::SelfFaculty, Faculty::Imagination, Faculty::Hands, Faculty::Soul, Faculty::Life, Faculty::Eyes, Faculty::Operator, Faculty::Member, Faculty::Steward]; + let outcome_of = |faculty: Faculty| { + let mut mind = desk(); + let mut answer = held_after_one(&mut mind); + answer.request_id = fresh_id(); + answer.sequence = 2; + let refused = admit_as(&mut mind, faculty, vec![D::DeskAnswer(answer)]); + let ordinary = admit_as(&mut mind, faculty, vec![question("Q9", &["A", "B"], "A")]); + (format!("{refused:?}"), matches!(ordinary, PipelineAdmissionOutcome::Committed { .. })) + }; + let baseline = outcome_of(Faculty::Hands); + assert!(baseline.1 && baseline.0.contains("Refused"), "{baseline:?}"); + for faculty in all { + assert_eq!(outcome_of(faculty), baseline, "{faculty:?}"); + } + } + + fn held_after_one(mind: &mut Mind) -> PipelineDeskAnswer { + let (answer, _) = stored(ask(mind, &decider("x"), pick(FORK, "Q1", "A", AT))); + held(mind, &answer) + } + + /// The digest form is frozen: a fixed request digests to a fixed hex, computed outside this code. + #[test] + fn body_digest_is_pinned() { + let request = |body: DeskBody| DeskAnswerRequest { + fork: s("eureka-state:question:Q1"), + options_hash: Sha256Hex("a".repeat(64)), + body, + answered_at: s("2026-11-10T12:00:00Z"), + request_id: RequestId(R1.into()), + }; + let answer = request(DeskBody::Answer { choice: Some(s("A")), note: None }); + assert_eq!(body_digest(&answer).0, "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a"); + let retraction = request(DeskBody::Retraction { retracts: s("eureka-state:desk_answer:Q1.1") }); + assert_eq!(body_digest(&retraction).0, "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3"); + // The id is not part of the body; answered_at is. + let mut other_id = answer.clone(); + other_id.request_id = RequestId(R2.into()); + assert_eq!(body_digest(&other_id), body_digest(&answer)); + let mut other_time = answer.clone(); + other_time.answered_at = s("2026-11-10T12:00:01Z"); + assert_ne!(body_digest(&other_time), body_digest(&answer)); + } + + /// The retry lookup follows the shape check: a malformed body reusing a bound id is + /// desk_request_invalid, never a conflict, and stores nothing. + #[test] + fn the_lookup_comes_after_the_shape_check() { + let mut mind = desk(); + stored(ask(&mut mind, &decider("x"), with_id(pick(FORK, "Q1", "A", AT), R1))); + let before = head(&mind); + let mut blank = with_id(pick(FORK, "Q1", "A", AT), R1); + blank.body = DeskBody::Answer { choice: None, note: None }; + let mut bad_time = with_id(pick(FORK, "Q1", "A", AT), R1); + bad_time.answered_at = s("yesterday"); + let mut bad_fork = with_id(pick(FORK, "Q1", "A", AT), R1); + bad_fork.fork = s("not-a-question"); + for (request, field) in [(blank, "body"), (bad_time, "answered_at"), (bad_fork, "fork")] { + assert_eq!(ask(&mut mind, &decider("x"), request), DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: field.into() }), "{field}"); + } + assert_eq!(head(&mind), before); + } + + /// claim_in_future compares at sub-second precision: 300.0 s ahead is not flagged, 300.5 s is. + #[test] + fn claim_in_future_is_decided_below_the_second() { + let mut mind = desk(); + for (claim, flagged) in [("2026-09-16T12:05:00.000Z", false), ("2026-09-16T12:05:00.500Z", true), ("2026-09-16T12:04:59.900Z", false)] { + let (answer, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", claim))); + assert_eq!(held(&mind, &answer).claim_in_future, flagged, "{claim}"); + } + } + + fn code_of(outcome: DeskAnswerOutcome) -> DeskRefusal { + match stored(outcome).1 { + DeskOutcome::Refused { code, .. } => code, + other => panic!("{other:?}"), + } + } + + /// R1: the wire's three illegal retraction forms are a stored `bad_retraction`, after steps 4 to 6. + #[test] + fn an_illegal_retraction_form_is_a_stored_bad_retraction() { + let mut mind = desk(); + let (first, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", AT))); + let form = |choice: Option<&str>, note: Option<&str>, retracts: Option<&str>| { + let mut request = pick(FORK, "Q1", "A", AT); + request.body = DeskBody::IllegalRetraction { choice: choice.map(s), note: note.map(Into::into), retracts: retracts.map(s) }; + request + }; + for (what, request, sent) in [ + ("a retraction carrying an answer", form(Some("A"), Some("why"), Some(&first)), (Some("A"), Some("why"), Some(first.as_str()))), + ("a retraction lacking retracts", form(None, None, None), (None, None, None)), + ("a plain answer carrying retracts", form(Some("A"), None, Some(&first)), (Some("A"), None, Some(first.as_str()))), + ] { + let before = head(&mind); + let id = request.request_id.clone(); + let (answer, outcome) = stored(ask(&mut mind, &decider("y"), request.clone())); + assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::BadRetraction, retracts: sent.2.map(|target| s(target)) }, "{what}"); + assert_eq!(head(&mind), before + 1, "{what} is stored"); + // The record keeps what was sent: the choice, the note and the target it named. + let record = held(&mind, &answer); + assert_eq!((record.choice, record.note, record.request_id), (sent.0.map(s), sent.1.map(Into::into), id), "{what}"); + // The stored refusal binds its request id and replays unchanged. + assert_eq!(replayed(ask(&mut mind, &decider("y"), request)), (answer, outcome), "{what}"); + assert_eq!(head(&mind), before + 1, "{what} replay writes nothing"); + } + assert_eq!(counted(&mind, FORK), Some(first)); + // Steps 4 to 6 come first: an off-list account is not_a_decider, a stale card is stale_fork. + assert_eq!(code_of(ask(&mut mind, &off(), form(None, None, None))), DeskRefusal::NotADecider); + let mut stale = form(None, None, None); + stale.options_hash = options_hash(&question_with("Q1", &["A", "B"], "B")); + assert_eq!(code_of(ask(&mut mind, &decider("y"), stale)), DeskRefusal::StaleFork); + } + + /// The illegal form is its own body: the same id with the same fields as a legal answer conflicts. + #[test] + fn an_illegal_retraction_is_not_the_same_body_as_a_plain_answer() { + let mut mind = desk(); + let plain = with_id(pick(FORK, "Q1", "A", AT), R1); + stored(ask(&mut mind, &decider("x"), plain.clone())); + let mut illegal = plain; + illegal.body = DeskBody::IllegalRetraction { choice: Some(s("A")), note: None, retracts: None }; + assert_eq!( + ask(&mut mind, &decider("x"), illegal), + DeskAnswerOutcome::Refused(MindRefusal::DeskRequestIdConflict { request_id: R1.into() }) + ); + } + + /// An illegal form still has to be a well-formed body, and an unknown fork still stores nothing. + #[test] + fn a_malformed_illegal_retraction_or_unknown_fork_stores_nothing() { + let mut mind = desk(); + let before = head(&mind); + let illegal = |fork: &str, note: Option<&str>| { + let mut request = pick(fork, "Q1", "A", AT); + request.body = DeskBody::IllegalRetraction { choice: None, note: note.map(Into::into), retracts: None }; + request + }; + assert!(matches!( + ask(&mut mind, &decider("x"), illegal("eureka-state:question:nope", None)), + DeskAnswerOutcome::Refused(MindRefusal::MissingReference { .. }) + )); + assert_eq!( + ask(&mut mind, &decider("x"), illegal(FORK, Some(" "))), + DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: "desk_answer.note".into() }) + ); + assert_eq!(head(&mind), before); + } + + /// Step 7: a choice that is no option label is a stored `choice_invalid`, whatever it looks like; + /// an empty or oversize one is a malformed body and stores nothing. + #[test] + fn a_choice_that_is_no_label_is_a_stored_choice_invalid() { + let mut mind = desk(); + let ask_choice = |mind: &mut Mind, choice: &str| { + let mut request = pick(FORK, "Q1", "A", AT); + request.body = DeskBody::Answer { choice: Some(s(choice)), note: None }; + let id = request.request_id.clone(); + (ask(mind, &decider("x"), request.clone()), request, id) + }; + for choice in ["has space", "ä", "A/B", &"x".repeat(65)] { + let before = head(&mind); + let (outcome, request, id) = ask_choice(&mut mind, choice); + let (answer, outcome) = stored(outcome); + assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::ChoiceInvalid, retracts: None }, "{choice}"); + assert_eq!(head(&mind), before + 1); + let record = held(&mind, &answer); + assert_eq!((record.choice, record.request_id), (Some(s(choice)), id), "{choice} is kept as sent"); + assert_eq!(replayed(ask(&mut mind, &decider("x"), request)), (answer, outcome)); + assert_eq!(head(&mind), before + 1); + } + // Rule order: a stale card or an off-list account outranks it. + let mut off_list = pick(FORK, "Q1", "A", AT); + off_list.body = DeskBody::Answer { choice: Some(s("has space")), note: None }; + assert_eq!(code_of(ask(&mut mind, &off(), off_list)), DeskRefusal::NotADecider); + assert_eq!(counted(&mind, FORK), None); + // Malformed: stores nothing and binds nothing. + let before = head(&mind); + let mut last = None; + for choice in ["", &"x".repeat(201)] { + let (outcome, request, _) = ask_choice(&mut mind, choice); + assert_eq!(outcome, DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: "desk_answer.choice".into() })); + assert_eq!(head(&mind), before); + last = Some(request); + } + let mut fixed = last.unwrap(); + fixed.body = DeskBody::Answer { choice: Some(s("A")), note: None }; + assert!(matches!(ask(&mut mind, &decider("x"), fixed), DeskAnswerOutcome::Stored { .. }), "its id stayed free"); + assert_eq!(head(&mind), before + 1); + } + + /// claim_in_future is decided against the stored received_at (whole seconds), not the clock + /// reading: at 12:00:00.6 the stored instant is 12:00:00, so a claim at 12:05:00.2 is 300.2 s + /// ahead of what the record says and is flagged, though 299.6 s ahead of the clock. + #[test] + fn claim_in_future_follows_the_stored_received_at() { + let mut mind = desk(); + let clock = now() + Duration::milliseconds(600); + for (claim, flagged) in [("2026-09-16T12:05:00.000Z", false), ("2026-09-16T12:05:00.200Z", true), ("2026-09-16T12:05:00.600Z", true)] { + let (answer, _) = stored(mind.answer_desk(decider("x"), pick(FORK, "Q1", "A", claim), clock)); + let record = held(&mind, &answer); + assert_eq!((record.received_at, record.claim_in_future), (s("2026-09-16T12:00:00Z"), flagged), "{claim}"); + } + } +} diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index afe1ae6..ec5c99d 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -7,7 +7,7 @@ use cultcache_rs::CultCacheEnvelope; use eureka_pipeline::{ - Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, + DeskOutcome, Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, ResearchSource, RunOperator, Short, Slug, }; use schemars::JsonSchema; @@ -382,7 +382,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option> { | D::Run(_) | D::Pulse(_) | D::Reply(_) - | D::Thought(_) => None, + | D::Thought(_) + | D::DeskAnswer(_) => None, } } @@ -434,10 +435,12 @@ pub enum CitationRole { Run, Reads, DayThrough, + Fork, + Answer, } impl CitationRole { - pub const ALL: [CitationRole; 24] = [ + pub const ALL: [CitationRole; 26] = [ Self::RaisedIn, Self::Answers, Self::Rulings, @@ -462,6 +465,8 @@ impl CitationRole { Self::Run, Self::Reads, Self::DayThrough, + Self::Fork, + Self::Answer, ]; /// The wire spelling, equal to the referring field's name. @@ -491,6 +496,8 @@ impl CitationRole { Self::Run => "run", Self::Reads => "reads", Self::DayThrough => "day_through", + Self::Fork => "fork", + Self::Answer => "answer", } } @@ -517,6 +524,7 @@ impl CitationRole { Self::RestsOn | Self::Toward => K::Idea, Self::Run => K::Reply, Self::Reads => K::Thought, + Self::Fork | Self::Answer => K::DeskAnswer, } } @@ -526,7 +534,7 @@ impl CitationRole { pub fn target_kinds(self) -> Vec { use PipelineKind as K; match self { - Self::Answers | Self::Questions | Self::Forks => vec![K::Question], + Self::Answers | Self::Questions | Self::Forks | Self::Fork => vec![K::Question], Self::Rulings => vec![K::Ruling], Self::CutSpec => vec![K::CutSpec], Self::CutReport => vec![K::CutReport], @@ -535,6 +543,7 @@ impl CitationRole { Self::Claims => vec![K::CutSpec, K::Finding, K::FollowUp], Self::Consolidates | Self::DayThrough => vec![K::Memory], Self::Run => vec![K::Run], + Self::Answer => vec![K::DeskAnswer], Self::RaisedIn | Self::Source | Self::Subject @@ -636,6 +645,13 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel edges.extend(idea.rests_on.iter().map(|to| (R::RestsOn, to.clone()))); edges.extend(idea.toward.iter().map(|to| (R::Toward, to.clone()))); } + D::DeskAnswer(answer) => { + edges.push((R::Fork, cited(K::Question, &answer.fork))); + match &answer.outcome { + DeskOutcome::Revision { of: id } | DeskOutcome::Retraction { retracts: id } => edges.push((R::Answer, cited(K::DeskAnswer, id))), + DeskOutcome::Counted | DeskOutcome::Refused { .. } => {} + } + } D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {} } edges diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index eedae5a..2d01966 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -102,7 +102,8 @@ pub fn index_text(document: &PipelineDocument) -> Option { lines.push(&idea.idea.0); lines.push(&idea.tradeoffs.0); } - D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) => {} + // A desk answer is a person's words, not indexed. + D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) => {} } lines.retain(|line| !line.is_empty()); if lines.is_empty() { diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 337778d..21c9e8e 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -27,6 +27,7 @@ //! `now` is passed in. pub mod admission; +mod desk; mod docs; mod image; pub mod envelope; @@ -52,6 +53,7 @@ mod read_tests; #[cfg(test)] mod move_tests; +pub use desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, options_hash}; pub use docs::CitationRole; pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text}; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; diff --git a/crates/huginn-mind/src/oracle.rs b/crates/huginn-mind/src/oracle.rs index 4066d94..bd38334 100644 --- a/crates/huginn-mind/src/oracle.rs +++ b/crates/huginn-mind/src/oracle.rs @@ -18,7 +18,7 @@ use eureka_pipeline::{ Line, Para, Short, Slug, StructuralDelta, pipeline_key, }; -use crate::docs::{citations, kind_of_type}; +use crate::docs::{Docs, citations, kind_of_type}; use crate::mind::{Mind, schema_cache}; use crate::persona::HuginnPersonaEntry; use crate::query::{AdmissionFacts, PipelineDocumentView, PipelineSelectionPage, PipelineStatus}; @@ -168,7 +168,7 @@ pub fn oracle_page(mind: &Mind, selection: &Selection, as_of: u let rows: Vec = visible.iter().map(|doc| row_of(&visible, doc)).collect(); let rows: Vec<&SelectionRow> = rows.iter().collect(); let evaluation = select(&Vocabulary, &rows, &selection, as_of, mind.cursor_key())?; - Mind::::page(&selection, evaluation, as_of, None) + Mind::::page(&selection, evaluation, &Docs::at(mind.image()?, as_of), as_of, None) } /// The view of one document at `as_of`, derived from the store alone. diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index cb4e79d..22292a6 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -31,13 +31,14 @@ use cultnet_rs::{ Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Selection, select, validate, }; use eureka_pipeline::{ - ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MemoryOrigin, MemorySort, OrgRepo, + ClaimOutcome, CommitRange, Date, DeskAuthority, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MemoryOrigin, MemorySort, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Short, Slug, Title, pipeline_key, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::desk::counted_answer; use crate::docs::{CitationRole, Docs, kind_of_type}; use crate::image::{Image, status_of}; use crate::mind::{Mind, schema_cache}; @@ -161,10 +162,15 @@ pub enum PipelineFacts { Memory { label: Label, sort: MemorySort, about: Vec, origin: MemoryOrigin, salience_permille: u16 }, Research { pass: Label, label: Label, claim: Line, inferred: bool, observed_on: Date }, Idea { pass: Label, label: Label, title: Title, proposed_on: Date }, + /// `outcome` is the stored, write-time outcome's variant name. `counts` is whether this answer is + /// 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 }, } impl PipelineFacts { - fn of(document: &PipelineDocument) -> Self { + /// `counted_of` answers, for a fork, the key of the answer that counts at the read's snapshot. + fn of(id: &PipelineRef, document: &PipelineDocument, counted_of: &dyn Fn(&str) -> Option) -> Self { use PipelineDocument as D; match document { D::Campaign(value) => Self::Campaign { title: value.title.clone(), repos: value.repos.clone() }, @@ -293,13 +299,22 @@ impl PipelineFacts { title: value.title.clone(), proposed_on: value.proposed_on.clone(), }, + D::DeskAnswer(value) => Self::DeskAnswer { + fork: value.fork.clone(), + sequence: value.sequence, + outcome: Short(value.outcome.name().into()), + counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()), + authority: value.authority, + }, } } } impl PipelineDocumentSummary { /// The header of a view: what a `headers` projection answers for it. - pub fn of(view: &PipelineDocumentView) -> Self { + /// `counted_of` names, for a desk fork, the answer that counts at the snapshot the view was read + /// at; a caller that summarises no desk answers passes `&|_| None`. + pub fn of(view: &PipelineDocumentView, counted_of: &dyn Fn(&str) -> Option) -> Self { Self { id: view.id.clone(), admission: view.admission.clone(), @@ -309,7 +324,7 @@ impl PipelineDocumentSummary { PipelineStatusSummary::Resolved { resolution: resolution.clone(), outcome: record.outcome.clone() } } }, - facts: PipelineFacts::of(&view.document), + facts: PipelineFacts::of(&view.id, &view.document, counted_of), } } } @@ -639,7 +654,7 @@ impl Mind { let ids = reader.universe(selection); let rows = reader.rows(&ids); let evaluation = select(&Vocabulary, &rows, selection, as_of, self.cursor_key())?; - Self::page(selection, evaluation, as_of, None) + Self::page(selection, evaluation, &reader.docs, as_of, None) } /// Every root this mind holds a document under, with its newest admission and, for a root that is an in-force @@ -807,7 +822,7 @@ impl Mind { kept.sort_by_key(|(at, _)| *at); evaluation.edges = kept.into_iter().map(|(_, edge)| edge).collect(); evaluation.matched = u32::try_from(members.ids.len()).unwrap_or(u32::MAX); - Ok(Ranked { page: Self::page(&selection, evaluation, as_of, Some(row_scores))?, dropped }) + Ok(Ranked { page: Self::page(&selection, evaluation, &reader.docs, as_of, Some(row_scores))?, dropped }) } /// The projection of an evaluation: a header or a whole view per row, and @@ -815,13 +830,14 @@ impl Mind { pub(crate) fn page( selection: &Selection, evaluation: Evaluation<&SelectionRow>, + docs: &Docs<'_>, as_of: u64, scores: Option>, ) -> Result { let items = if selection.projection == PROJECTION_DOCUMENT { PipelinePageItems::Documents(evaluation.rows.iter().map(|row| row.view.clone()).collect()) } else { - PipelinePageItems::Headers(evaluation.rows.iter().map(|row| PipelineDocumentSummary::of(&row.view)).collect()) + PipelinePageItems::Headers(evaluation.rows.iter().map(|row| PipelineDocumentSummary::of(&row.view, &|fork| counted_answer(docs, fork))).collect()) }; let edges = if selection.has_hop() { let mut typed = Vec::with_capacity(evaluation.edges.len()); @@ -1726,7 +1742,7 @@ mod tests { let page = mind.query(&one).unwrap(); let expected = view(&mind, K::Question, &q1); assert_eq!(page.edges, None, "no hop, no edges"); - assert_eq!(headers(&page), [PipelineDocumentSummary::of(&expected)]); + assert_eq!(headers(&page), [PipelineDocumentSummary::of(&expected, &|_| None)]); assert_eq!(headers(&page)[0].facts, PipelineFacts::Question { label: l("Q1"), title: "Who owns the state?".into(), @@ -1748,7 +1764,7 @@ mod tests { thought.nearest_permille = vec![300, 450]; thought.chosen = 1; thought.text = "B.".into(); - assert_eq!(PipelineFacts::of(&D::Thought(thought)), PipelineFacts::Thought { label: "t1".into(), chosen: 1, nearest_permille: 450 }); + assert_eq!(PipelineFacts::of(&PipelineRef { kind: K::Thought, id: id("thought", "t1").as_str().into() }, &D::Thought(thought), &|_| None), PipelineFacts::Thought { label: "t1".into(), chosen: 1, nearest_permille: 450 }); } #[test] @@ -1812,6 +1828,21 @@ mod tests { way_document.toward = vec![r(K::Question, &q1)]; committed(admit(&mut mind, vec![D::Research(note_document), D::Idea(way_document)])); + // 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 pick = |n: u32| crate::desk::DeskAnswerRequest { + fork: s(&q2), + options_hash: crate::desk::options_hash(&shown), + body: crate::desk::DeskBody::Answer { choice: Some(s("A")), note: None }, + answered_at: s("2026-11-10T12:00:00Z"), + request_id: eureka_pipeline::RequestId(format!("00000000-0000-4000-8000-{n:012x}")), + }; + let (answer_1, answer_2) = (id("desk_answer", "Q2.n1"), id("desk_answer", "Q2.n2")); + for n in 1..=2 { + assert!(matches!(mind.answer_desk(stamp.clone(), pick(n), now()), crate::desk::DeskAnswerOutcome::Stored { .. })); + } + let (n1, f1_closed, f2_closed) = ( id("resolution", "question.Q1.n1"), id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())), @@ -1857,8 +1888,11 @@ mod tests { (CitationRole::Sources, ¬e, &f1), (CitationRole::RestsOn, &way, ¬e), (CitationRole::Toward, &way, &q1), + (CitationRole::Fork, &answer_1, &q2), + (CitationRole::Fork, &answer_2, &q2), + (CitationRole::Answer, &answer_2, &answer_1), ]; - assert_eq!(CitationRole::ALL.len(), 24); + assert_eq!(CitationRole::ALL.len(), 26); for role in CitationRole::ALL { let selection = Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() }; diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index e929cd4..2ffa3a8 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -29,7 +29,9 @@ pub const RECEIPT_SCHEMA_VERSION: &str = "huginn.mind_commit_receipt.v2"; /// Which faculty a batch was admitted as. Attribution, not authority /// (ruling 18): no admission rule trusts it, and Cut 9's views read it back -/// from the receipt. +/// from the receipt. `Member` and `Steward` name a desk answerer who is neither the +/// operator nor a faculty; they confer no admission right anywhere (a test admits a +/// batch under each and checks the outcomes match). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum Faculty { SelfFaculty, @@ -40,6 +42,8 @@ pub enum Faculty { Life, Eyes, Operator, + Member, + Steward, } /// Who admitted a batch: the faculty, the agent, its session and the tool. diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index a5c10b6..06dfb10 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -44,6 +44,12 @@ pub enum MindRefusal { InvalidOptions { question: String }, InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, + /// A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key. + DeskAnswerOutsideDesk { id: String }, + /// A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id). + DeskRequestInvalid { field: String }, + /// The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored. + DeskRequestIdConflict { request_id: String }, /// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos. EmptyRepos { campaign: String }, RepoNotInCampaign { repo: String }, diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index ed5f344..c8370bd 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -63,7 +63,7 @@ pub(crate) enum Alias { Pass, } -const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"]; +const FACULTIES: [&str; 9] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator", "Member", "Steward"]; const OUTCOMES: [&str; 8] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn", "Slept", "Moved"]; const BOOLEANS: [&str; 2] = ["true", "false"]; const MEMORY_ORIGINS: [&str; 5] = ["Room", "Direct", "Wake", "SelfRun", "Sleep"]; @@ -246,7 +246,7 @@ impl SelectionRow { D::FollowUp(follow_up) => vec![follow_up.repo.identity()], D::Stewardship(stewardship) => vec![stewardship.repo.identity()], D::HandOff(hand_off) => vec![hand_off.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::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(_) => { Vec::new() } }, @@ -435,6 +435,20 @@ mod tests { names } + /// The `faculty` alias values are exactly Faculty's serde names; the exhaustive match makes + /// a new variant a compile error here until FACULTIES names it. + #[test] + fn the_faculty_alias_values_are_the_faculty_serde_names() { + use crate::receipt::Faculty; + let named = |faculty: Faculty| match faculty { + Faculty::SelfFaculty | Faculty::Imagination | Faculty::Hands | Faculty::Soul | Faculty::Life | Faculty::Eyes | Faculty::Operator | Faculty::Member | Faculty::Steward => { + serde_json::to_value(faculty).unwrap().as_str().unwrap().to_string() + } + }; + let all = [Faculty::SelfFaculty, Faculty::Imagination, Faculty::Hands, Faculty::Soul, Faculty::Life, Faculty::Eyes, Faculty::Operator, Faculty::Member, Faculty::Steward]; + assert_eq!(sorted(all.into_iter().map(named).collect()), strings(&FACULTIES)); + } + fn strings(names: &[&str]) -> Vec { sorted(names.iter().map(|name| name.to_string()).collect()) } @@ -445,7 +459,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]); 20] = [ + let table: [(K, &[&str]); 21] = [ (K::Campaign, &["repo"]), (K::Target, &[]), (K::Question, &[]), @@ -466,6 +480,7 @@ mod tests { (K::Idea, &["pass"]), (K::Reply, &[]), (K::Thought, &[]), + (K::DeskAnswer, &[]), ]; for (kind, extra) in table { let want = [&["root", "in_force", "faculty"][..], extra].concat(); @@ -478,10 +493,10 @@ mod tests { /// Each role is declared by the one kind whose field carries it, and its /// targets are the kinds that field is typed to, "any" spelled as all - /// twenty. + /// twenty-one. #[test] fn each_kind_declares_the_roles_it_carries_and_their_targets() { - let table: [(K, &[&str]); 20] = [ + let table: [(K, &[&str]); 21] = [ (K::Campaign, &[]), (K::Target, &[]), (K::Question, &["raised_in"]), @@ -502,13 +517,14 @@ mod tests { (K::Idea, &["rests_on", "toward"]), (K::Reply, &["run"]), (K::Thought, &["reads"]), + (K::DeskAnswer, &["fork", "answer"]), ]; 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(), 20); + assert_eq!(Vocabulary.target_leaves("subject").len(), 21); assert_eq!( sorted(Vocabulary.target_leaves("claims")), sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect()) diff --git a/crates/huginn-mind/vectors/fork-card-options-hash.json b/crates/huginn-mind/vectors/fork-card-options-hash.json new file mode 100644 index 0000000..0faa02b --- /dev/null +++ b/crates/huginn-mind/vectors/fork-card-options-hash.json @@ -0,0 +1,343 @@ +[ + { + "name": "heimdall_A_five_members", + "heimdall": "A", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + { + "name": "heimdall_B_recommendation_changed", + "heimdall": "B", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "yes", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" + }, + { + "name": "heimdall_C_option_order_changed", + "heimdall": "C", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "no", + "text": "Keep it local." + }, + { + "id": "yes", + "text": "Share it." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5" + }, + { + "name": "heimdall_D_nfc_escapes_astral", + "heimdall": "D", + "title": "Café “desk”", + "context": "Line one\nLine two — 😀", + "options": [ + { + "id": "a", + "text": "x" + }, + { + "id": "b", + "text": "y" + } + ], + "recommendation": "a", + "depends": [], + "canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}", + "canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D", + "sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272" + }, + { + "name": "heimdall_E_decomposed_equals_D", + "heimdall": "E", + "title": "Café “desk”", + "context": "Line one\nLine two — 😀", + "options": [ + { + "id": "a", + "text": "x" + }, + { + "id": "b", + "text": "y" + } + ], + "recommendation": "a", + "depends": [], + "canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}", + "canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D", + "sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272" + }, + { + "name": "heimdall_F_option_id_changed", + "heimdall": "F", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "share", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40" + }, + { + "name": "heimdall_G_depends_emptied", + "heimdall": "G", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86" + }, + { + "name": "option_text_changed", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it!" + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6" + }, + { + "name": "title_changed", + "heimdall": null, + "title": "Slot mark consent?", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D", + "sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f" + }, + { + "name": "context_changed", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should slot marks be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173" + }, + { + "name": "depends_order_changed", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience", + "x:question:y" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d" + }, + { + "name": "depends_order_changed_reversed", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "x:question:y", + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796" + }, + { + "name": "hidden_members_do_not_move_it", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + { + "name": "control_characters_and_markup_are_escaped_minimally", + "heimdall": null, + "title": "q \" b \\ t \t n \n bell \u0007 del ", + "context": " 
 &", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\" 
 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \"}", + "canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D", + "sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe" + }, + { + "name": "astral_option_id_and_text", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "😀", + "text": "𝄞" + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"😀\",\"text\":\"𝄞\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac" + } + ] \ No newline at end of file diff --git a/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json b/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json new file mode 100644 index 0000000..3694e88 --- /dev/null +++ b/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json @@ -0,0 +1,210 @@ +{ + "$defs": { + "DeskAuthority": { + "enum": [ + "Operator", + "Steward", + "Member" + ], + "type": "string" + }, + "DeskOutcome": { + "description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).", + "oneOf": [ + { + "const": "Counted", + "description": "The answer that counts for its fork when it was admitted.", + "type": "string" + }, + { + "additionalProperties": false, + "description": "A later passing answer, kept visible; `of` is the counted answer it followed.", + "properties": { + "Revision": { + "properties": { + "of": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "of" + ], + "type": "object" + } + }, + "required": [ + "Revision" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Retraction": { + "properties": { + "retracts": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "retracts" + ], + "type": "object" + } + }, + "required": [ + "Retraction" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.", + "properties": { + "Refused": { + "properties": { + "code": { + "$ref": "#/$defs/DeskRefusal" + }, + "retracts": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code" + ], + "type": "object" + } + }, + "required": [ + "Refused" + ], + "type": "object" + } + ] + }, + "DeskRefusal": { + "enum": [ + "StaleFork", + "NotADecider", + "ForkClosed", + "ChoiceInvalid", + "BadRetraction", + "AlreadyRetracted" + ], + "type": "string" + }, + "RequestId": { + "description": "A client's retry token: a lowercase D-format UUID.", + "maxLength": 36, + "type": "string" + }, + "Sha256Hex": { + "description": "A SHA-256 digest: exactly 64 lowercase hex characters.", + "maxLength": 64, + "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" + }, + "Words": { + "description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).", + "maxLength": 4000, + "minLength": 1, + "type": "string" + } + }, + "$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_answer.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.", + "properties": { + "answered_at": { + "$ref": "#/$defs/Short" + }, + "answerer": { + "$ref": "#/$defs/Short" + }, + "authority": { + "$ref": "#/$defs/DeskAuthority" + }, + "body_sha256": { + "$ref": "#/$defs/Sha256Hex" + }, + "campaign": { + "$ref": "#/$defs/Slug" + }, + "choice": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "claim_in_future": { + "type": "boolean" + }, + "fork": { + "$ref": "#/$defs/Short" + }, + "note": { + "anyOf": [ + { + "$ref": "#/$defs/Words" + }, + { + "type": "null" + } + ] + }, + "options_hash": { + "$ref": "#/$defs/Sha256Hex" + }, + "outcome": { + "$ref": "#/$defs/DeskOutcome" + }, + "received_at": { + "$ref": "#/$defs/Short" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "sequence": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "campaign", + "fork", + "sequence", + "options_hash", + "answered_at", + "received_at", + "claim_in_future", + "request_id", + "body_sha256", + "answerer", + "authority", + "outcome" + ], + "title": "PipelineDeskAnswer", + "type": "object" +} diff --git a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json index c796942..c5731bf 100644 --- a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json @@ -177,7 +177,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json index d956521..e1c33ea 100644 --- a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json @@ -181,7 +181,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json b/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json index 38e2b48..0a310d0 100644 --- a/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json @@ -36,7 +36,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json b/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json index a2b2f66..884814c 100644 --- a/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json @@ -153,7 +153,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json index 5d47c9f..1b06f45 100644 --- a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json @@ -41,7 +41,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json b/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json index 706efa9..fa0ddf6 100644 --- a/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json @@ -31,7 +31,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.research.v2.schema.json b/schemas/cultnet/epiphany.pipeline.research.v2.schema.json index 3e70f18..390ba7d 100644 --- a/schemas/cultnet/epiphany.pipeline.research.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.research.v2.schema.json @@ -103,7 +103,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json index 71984aa..224f289 100644 --- a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json @@ -36,7 +36,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json index 9740c65..f555a4e 100644 --- a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json @@ -74,7 +74,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.run.v2.schema.json b/schemas/cultnet/epiphany.pipeline.run.v2.schema.json index 70c79e9..38499f9 100644 --- a/schemas/cultnet/epiphany.pipeline.run.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.run.v2.schema.json @@ -31,7 +31,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json b/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json index 3ce3347..d2a5893 100644 --- a/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json @@ -36,7 +36,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ], "type": "string" }, diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 58844d6..d3fa787 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -395,6 +395,107 @@ "type": "string", "maxLength": 10 }, + "DeskAuthority": { + "type": "string", + "enum": [ + "Operator", + "Steward", + "Member" + ] + }, + "DeskOutcome": { + "description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).", + "oneOf": [ + { + "description": "The answer that counts for its fork when it was admitted.", + "type": "string", + "const": "Counted" + }, + { + "description": "A later passing answer, kept visible; `of` is the counted answer it followed.", + "type": "object", + "properties": { + "Revision": { + "type": "object", + "properties": { + "of": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Revision" + ] + }, + { + "type": "object", + "properties": { + "Retraction": { + "type": "object", + "properties": { + "retracts": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "retracts" + ] + } + }, + "additionalProperties": false, + "required": [ + "Retraction" + ] + }, + { + "description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.", + "type": "object", + "properties": { + "Refused": { + "type": "object", + "properties": { + "code": { + "$ref": "#/$defs/DeskRefusal" + }, + "retracts": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code" + ] + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + } + ] + }, + "DeskRefusal": { + "type": "string", + "enum": [ + "StaleFork", + "NotADecider", + "ForkClosed", + "ChoiceInvalid", + "BadRetraction", + "AlreadyRetracted" + ] + }, "Deviation": { "type": "object", "properties": { @@ -468,7 +569,7 @@ ] }, "Faculty": { - "description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt.", + "description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt. `Member` and `Steward` name a desk answerer who is neither the\noperator nor a faculty; they confer no admission right anywhere (a test admits a\nbatch under each and checks the outcomes match).", "type": "string", "enum": [ "SelfFaculty", @@ -477,7 +578,9 @@ "Soul", "Life", "Eyes", - "Operator" + "Operator", + "Member", + "Steward" ] }, "FileChange": { @@ -1091,6 +1194,84 @@ "questions" ] }, + "PipelineDeskAnswer": { + "description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.", + "type": "object", + "properties": { + "answered_at": { + "$ref": "#/$defs/Short" + }, + "answerer": { + "$ref": "#/$defs/Short" + }, + "authority": { + "$ref": "#/$defs/DeskAuthority" + }, + "body_sha256": { + "$ref": "#/$defs/Sha256Hex" + }, + "campaign": { + "$ref": "#/$defs/Slug" + }, + "choice": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "claim_in_future": { + "type": "boolean" + }, + "fork": { + "$ref": "#/$defs/Short" + }, + "note": { + "anyOf": [ + { + "$ref": "#/$defs/Words" + }, + { + "type": "null" + } + ] + }, + "options_hash": { + "$ref": "#/$defs/Sha256Hex" + }, + "outcome": { + "$ref": "#/$defs/DeskOutcome" + }, + "received_at": { + "$ref": "#/$defs/Short" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "sequence": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "campaign", + "fork", + "sequence", + "options_hash", + "answered_at", + "received_at", + "claim_in_future", + "request_id", + "body_sha256", + "answerer", + "authority", + "outcome" + ] + }, "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": [ @@ -1413,6 +1594,22 @@ "kind", "value" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_answer" + }, + "value": { + "$ref": "#/$defs/PipelineDeskAnswer" + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -1679,7 +1876,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ] }, "PipelineMemory": { @@ -2432,6 +2630,11 @@ "builds" ] }, + "RequestId": { + "description": "A client's retry token: a lowercase D-format UUID.", + "type": "string", + "maxLength": 36 + }, "ResearchSource": { "description": "Where a research claim comes from. A `Repo` pin and a `Web` page are\noutside the document set; an `Observed` result reuses a finding's evidence;\na `Document` is the one source that is a citation edge.", "oneOf": [ @@ -2977,6 +3180,12 @@ "name", "pins" ] + }, + "Words": { + "description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).", + "type": "string", + "maxLength": 4000, + "minLength": 1 } } } \ No newline at end of file diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 80d92eb..c3d6e4e 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -263,7 +263,9 @@ "toward", "run", "reads", - "day_through" + "day_through", + "fork", + "answer" ] }, "ClaimOutcome": { @@ -402,6 +404,107 @@ "manifest" ] }, + "DeskAuthority": { + "type": "string", + "enum": [ + "Operator", + "Steward", + "Member" + ] + }, + "DeskOutcome": { + "description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).", + "oneOf": [ + { + "description": "The answer that counts for its fork when it was admitted.", + "type": "string", + "const": "Counted" + }, + { + "description": "A later passing answer, kept visible; `of` is the counted answer it followed.", + "type": "object", + "properties": { + "Revision": { + "type": "object", + "properties": { + "of": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Revision" + ] + }, + { + "type": "object", + "properties": { + "Retraction": { + "type": "object", + "properties": { + "retracts": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "retracts" + ] + } + }, + "additionalProperties": false, + "required": [ + "Retraction" + ] + }, + { + "description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.", + "type": "object", + "properties": { + "Refused": { + "type": "object", + "properties": { + "code": { + "$ref": "#/$defs/DeskRefusal" + }, + "retracts": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code" + ] + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + } + ] + }, + "DeskRefusal": { + "type": "string", + "enum": [ + "StaleFork", + "NotADecider", + "ForkClosed", + "ChoiceInvalid", + "BadRetraction", + "AlreadyRetracted" + ] + }, "Deviation": { "type": "object", "properties": { @@ -475,7 +578,7 @@ ] }, "Faculty": { - "description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt.", + "description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt. `Member` and `Steward` name a desk answerer who is neither the\noperator nor a faculty; they confer no admission right anywhere (a test admits a\nbatch under each and checks the outcomes match).", "type": "string", "enum": [ "SelfFaculty", @@ -484,7 +587,9 @@ "Soul", "Life", "Eyes", - "Operator" + "Operator", + "Member", + "Steward" ] }, "FileChange": { @@ -1400,6 +1505,69 @@ "QuoteWithoutOperator" ] }, + { + "description": "A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key.", + "type": "object", + "properties": { + "DeskAnswerOutsideDesk": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskAnswerOutsideDesk" + ] + }, + { + "description": "A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id).", + "type": "object", + "properties": { + "DeskRequestInvalid": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + }, + "required": [ + "field" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskRequestInvalid" + ] + }, + { + "description": "The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored.", + "type": "object", + "properties": { + "DeskRequestIdConflict": { + "type": "object", + "properties": { + "request_id": { + "type": "string" + } + }, + "required": [ + "request_id" + ] + } + }, + "additionalProperties": false, + "required": [ + "DeskRequestIdConflict" + ] + }, { "description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.", "type": "object", @@ -2529,6 +2697,84 @@ "questions" ] }, + "PipelineDeskAnswer": { + "description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.", + "type": "object", + "properties": { + "answered_at": { + "$ref": "#/$defs/Short" + }, + "answerer": { + "$ref": "#/$defs/Short" + }, + "authority": { + "$ref": "#/$defs/DeskAuthority" + }, + "body_sha256": { + "$ref": "#/$defs/Sha256Hex" + }, + "campaign": { + "$ref": "#/$defs/Slug" + }, + "choice": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "claim_in_future": { + "type": "boolean" + }, + "fork": { + "$ref": "#/$defs/Short" + }, + "note": { + "anyOf": [ + { + "$ref": "#/$defs/Words" + }, + { + "type": "null" + } + ] + }, + "options_hash": { + "$ref": "#/$defs/Sha256Hex" + }, + "outcome": { + "$ref": "#/$defs/DeskOutcome" + }, + "received_at": { + "$ref": "#/$defs/Short" + }, + "request_id": { + "$ref": "#/$defs/RequestId" + }, + "sequence": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "campaign", + "fork", + "sequence", + "options_hash", + "answered_at", + "received_at", + "claim_in_future", + "request_id", + "body_sha256", + "answerer", + "authority", + "outcome" + ] + }, "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": [ @@ -2851,6 +3097,22 @@ "kind", "value" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_answer" + }, + "value": { + "$ref": "#/$defs/PipelineDeskAnswer" + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -3781,6 +4043,49 @@ "kind", "value" ] + }, + { + "description": "`outcome` is the stored, write-time outcome's variant name. `counts` is whether this answer is\nthe one that counts for its fork at the read's snapshot, derived by `counted_answer` and never\nstored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_answer" + }, + "value": { + "type": "object", + "properties": { + "authority": { + "$ref": "#/$defs/DeskAuthority" + }, + "counts": { + "type": "boolean" + }, + "fork": { + "$ref": "#/$defs/Short" + }, + "outcome": { + "$ref": "#/$defs/Short" + }, + "sequence": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "fork", + "sequence", + "outcome", + "counts", + "authority" + ] + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -4047,7 +4352,8 @@ "Research", "Idea", "Reply", - "Thought" + "Thought", + "DeskAnswer" ] }, "PipelineMemory": { @@ -5257,6 +5563,11 @@ "builds" ] }, + "RequestId": { + "description": "A client's retry token: a lowercase D-format UUID.", + "type": "string", + "maxLength": 36 + }, "ResearchSource": { "description": "Where a research claim comes from. A `Repo` pin and a `Web` page are\noutside the document set; an `Observed` result reuses a finding's evidence;\na `Document` is the one source that is a citation edge.", "oneOf": [ @@ -5861,6 +6172,12 @@ "name", "pins" ] + }, + "Words": { + "description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).", + "type": "string", + "maxLength": 4000, + "minLength": 1 } } } \ No newline at end of file diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index a5290b6..30f93b2 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -232,6 +232,17 @@ "title": "Epiphany Pipeline Thought v2", "path": "epiphany.pipeline.thought.v2.schema.json" }, + { + "schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_answer.v2.schema.json", + "kind": "document_payload", + "wireContracts": [ + "cultnet.schema.v0" + ], + "schemaVersion": "epiphany.pipeline.desk_answer.v2", + "documentType": "epiphany.pipeline.desk_answer.v2", + "title": "Epiphany Pipeline DeskAnswer v2", + "path": "epiphany.pipeline.desk_answer.v2.schema.json" + }, { "schemaId": "huginn.mind_request.v1", "kind": "wire_message",