diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index cf4f155..f14c1ef 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -1150,6 +1150,18 @@ pub enum DeskOutcome { Refused { code: DeskRefusal }, } +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 { 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/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..c1616d4 --- /dev/null +++ b/crates/huginn-mind/src/desk.rs @@ -0,0 +1,673 @@ +//! 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). 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. +//! +//! The decider list is an input (`DeskStamp`, from the caller); the Mind never +//! reads one. The request types carry no `answerer` and no `authority`. +//! `answered_at` is the client's claim, displayed and never compared: order is +//! the per-question sequence, which is the receiver's order. + +use chrono::{DateTime, Utc}; +use eureka_pipeline::{ + DeskAuthority, DeskOutcome, DeskRefusal, Label, Para, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, + PipelineRefusal, PipelineResolution, ResolutionOutcome, Sha256Hex, Short, Slug, +}; + +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, kept as written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeskAnswerRequest { + pub fork: Short, + pub options_hash: Sha256Hex, + pub body: DeskBody, + pub answered_at: Short, +} + +/// An answer, or the retraction of one: a sum, so a retraction carrying an +/// answer, or naming no target, cannot be written down (R1 by construction). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeskBody { + Answer { choice: Option