huginn-mind: Mind::answer_desk, the counting door for desk answers
Rules 3-8, R1, R2, R4 of the binding spec; counted_answer derives which answer counts; Door::Desk is the only door that may write a desk_answer, so Admit, import and derivation refuse it. options_hash sits behind one seam, failing closed until the bytes are filled; tests use a named fixture hash. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
be35c72a16
commit
719b5104f5
13 changed files with 1218 additions and 21 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CultCacheEnvelope, MindRefusal> {
|
||||
pub(crate) fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result<CultCacheEnvelope, MindRefusal> {
|
||||
pipeline_key(document).map_err(MindRefusal::Document)?;
|
||||
document.prepare(cache).map_err(unavailable)
|
||||
}
|
||||
|
|
@ -108,14 +128,15 @@ impl<S: MindStore> Mind<S> {
|
|||
envelopes: Vec<CultCacheEnvelope>,
|
||||
now: DateTime<Utc>,
|
||||
) -> 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<CultCacheEnvelope>,
|
||||
|
|
@ -139,6 +160,7 @@ impl<S: MindStore> Mind<S> {
|
|||
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<S: MindStore> Mind<S> {
|
|||
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() });
|
||||
|
|
|
|||
673
crates/huginn-mind/src/desk.rs
Normal file
673
crates/huginn-mind/src/desk.rs
Normal file
|
|
@ -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<Label>, note: Option<Para> },
|
||||
Retraction { retracts: Short },
|
||||
}
|
||||
|
||||
/// Who answered and in what standing, as the verified caller states it. The
|
||||
/// Mind copies both into the record and decides nothing from the caller's
|
||||
/// word except that `authority: None` is no decider (rule 5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeskStamp {
|
||||
pub answerer: Short,
|
||||
pub authority: Option<DeskAuthority>,
|
||||
}
|
||||
|
||||
/// `Stored`: the answer id (the record key), its outcome and the commit's receipt.
|
||||
/// `Refused`: nothing was stored (a shape fault, an unknown fork, a moved campaign,
|
||||
/// or a commit fault).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DeskAnswerOutcome {
|
||||
Stored { answer: Short, outcome: DeskOutcome, receipt_id: String },
|
||||
Refused(MindRefusal),
|
||||
}
|
||||
|
||||
/// The hash of a fork as the desk shows it, which an answer must quote (rule 4).
|
||||
///
|
||||
/// PENDING: the bytes are Libby's CultNet card contract (CultLib PR #72) and are
|
||||
/// not implemented in this seam yet. Until they are, the seam fails closed: no
|
||||
/// request can quote the value returned here, so every answer is stored
|
||||
/// `Refused { StaleFork }`. Tests are served by `fixtures::fixture_options_hash`.
|
||||
pub fn options_hash(question: &PipelineQuestion) -> Sha256Hex {
|
||||
#[cfg(test)]
|
||||
return fixtures::fixture_options_hash(question);
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = question;
|
||||
Sha256Hex("0".repeat(64))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every desk answer of one fork, with its key.
|
||||
fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator<Item = (&'a str, &'a PipelineDeskAnswer)> + '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))
|
||||
}
|
||||
|
||||
/// The answer that counts for `fork` now: the lowest-sequence answer whose stored
|
||||
/// outcome is Counted or Revision and that no Retraction names (R4). 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<String> {
|
||||
answers_of(docs, fork)
|
||||
.filter(|(key, answer)| matches!(answer.outcome, DeskOutcome::Counted | DeskOutcome::Revision { .. }) && !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<DeskRefusal> {
|
||||
let fork = request.fork.0.as_str();
|
||||
if request.options_hash != options_hash(question) {
|
||||
return Some(DeskRefusal::StaleFork);
|
||||
}
|
||||
if stamp.authority.is_none() {
|
||||
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 == *choice) => {
|
||||
Some(DeskRefusal::ChoiceInvalid)
|
||||
}
|
||||
DeskBody::Answer { .. } => None,
|
||||
DeskBody::Retraction { retracts } => {
|
||||
let target = answers_of(docs, fork).find(|(key, _)| *key == retracts.0);
|
||||
if !target.is_some_and(|(_, answer)| matches!(answer.outcome, DeskOutcome::Counted | DeskOutcome::Revision { .. })) {
|
||||
return Some(DeskRefusal::BadRetraction);
|
||||
}
|
||||
is_retracted(docs, fork, &retracts.0).then_some(DeskRefusal::AlreadyRetracted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<S: MindStore> Mind<S> {
|
||||
/// 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. See the module header for the order and the stored/not-stored line.
|
||||
pub fn answer_desk(&mut self, stamp: DeskStamp, request: DeskAnswerRequest, now: DateTime<Utc>) -> 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<Utc>) -> Result<DeskAnswerOutcome, MindRefusal> {
|
||||
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();
|
||||
if DateTime::parse_from_rfc3339(&request.answered_at.0).is_err() {
|
||||
return Err(invalid("answered_at"));
|
||||
}
|
||||
let (choice, note, shape_outcome) = match &request.body {
|
||||
DeskBody::Answer { choice, note } => {
|
||||
if choice.is_none() && note.is_none() {
|
||||
return Err(invalid("body"));
|
||||
}
|
||||
(choice.clone(), note.clone(), DeskOutcome::Counted)
|
||||
}
|
||||
DeskBody::Retraction { retracts } => (None, None, DeskOutcome::Retraction { retracts: retracts.clone() }),
|
||||
};
|
||||
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(),
|
||||
answerer: stamp.answerer.clone(),
|
||||
authority: stamp.authority,
|
||||
outcome,
|
||||
};
|
||||
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 image = self.image()?;
|
||||
let docs = Docs::at(image, image.head);
|
||||
let fork = request.fork.0.as_str();
|
||||
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 },
|
||||
None => match &request.body {
|
||||
DeskBody::Retraction { retracts } => DeskOutcome::Retraction { retracts: retracts.clone() },
|
||||
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())?;
|
||||
let provenance = PipelineProvenance {
|
||||
faculty: Faculty::Operator,
|
||||
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)]
|
||||
pub(crate) mod fixtures {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in for `options_hash` served to tests only: a deterministic function of every
|
||||
/// member the binding spec names, order-sensitive, NOT SHA-256 and NOT Libby's bytes.
|
||||
/// It lets the counting rules be tested while the real bytes are pending.
|
||||
pub(crate) fn fixture_options_hash(question: &PipelineQuestion) -> Sha256Hex {
|
||||
let text = format!(
|
||||
"{:?}|{:?}|{:?}|{:?}|{:?}",
|
||||
question.title.0,
|
||||
question.question.0,
|
||||
question.options.iter().map(|option| (&option.label.0, &option.text.0)).collect::<Vec<_>>(),
|
||||
question.recommended.0,
|
||||
question.depends.iter().map(|line| &line.0).collect::<Vec<_>>()
|
||||
);
|
||||
// Four rounds of FNV-1a with distinct seeds make 64 hex digits.
|
||||
let digits: String = (0u64..4)
|
||||
.map(|round| {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ round.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
for byte in text.bytes() {
|
||||
hash = (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
})
|
||||
.collect();
|
||||
Sha256Hex(digits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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<MemoryStore> {
|
||||
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"))
|
||||
}
|
||||
|
||||
fn decider(who: &str) -> DeskStamp {
|
||||
DeskStamp { answerer: s(who), authority: Some(DeskAuthority::Decider) }
|
||||
}
|
||||
|
||||
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(l(choice)), note: None },
|
||||
answered_at: s(answered_at),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
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, .. } => (answer.0, outcome),
|
||||
other => panic!("expected a stored answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn counted(mind: &Mind<MemoryStore>, fork: &str) -> Option<String> {
|
||||
let image = mind.image().unwrap();
|
||||
counted_answer(&Docs::at(image, image.head), fork)
|
||||
}
|
||||
|
||||
fn held(mind: &Mind<MemoryStore>, 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<MemoryStore>) -> u64 {
|
||||
mind.image().unwrap().head
|
||||
}
|
||||
|
||||
fn ask(mind: &mut Mind<MemoryStore>, 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(l("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(l("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 });
|
||||
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 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_off_the_decider_list_is_refused() {
|
||||
let mut mind = desk();
|
||||
let off = DeskStamp { answerer: s("nobody"), authority: None };
|
||||
let (_, outcome) = stored(ask(&mut mind, &off, pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z")));
|
||||
assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider });
|
||||
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 }
|
||||
);
|
||||
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 }
|
||||
);
|
||||
// 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(l("an-option")), note: None };
|
||||
assert_eq!(stored(ask(&mut mind, &decider("x"), by_text)).1, DeskOutcome::Refused { code: DeskRefusal::ChoiceInvalid });
|
||||
// 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 });
|
||||
// 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 = DeskStamp { answerer: s("nobody"), authority: None };
|
||||
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(l("Q")), note: None };
|
||||
request
|
||||
};
|
||||
let code = |mind: &mut Mind<MemoryStore>, 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<MemoryStore>, 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 = DeskStamp { answerer: s("nobody"), authority: None };
|
||||
assert_eq!(stored(ask(&mut mind, &off, retract(FORK, "Q1", &first))).1, DeskOutcome::Refused { code: DeskRefusal::NotADecider });
|
||||
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);
|
||||
}
|
||||
|
||||
#[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 = DeskStamp { answerer: s("guest"), authority: None };
|
||||
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 });
|
||||
assert_eq!((record.answerer, record.authority, record.answered_at), (s("guest"), None, 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::Decider)] {
|
||||
let stamp = DeskStamp { answerer: s(who), authority: Some(authority) };
|
||||
let (answer, _) = stored(ask(&mut mind, &stamp, pick(FORK, "Q1", "A", at)));
|
||||
let record = held(&mind, &answer);
|
||||
assert_eq!((record.answerer, record.authority), (s(who), Some(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 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"), (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() });
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Lineage<'_>> {
|
|||
| 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<PipelineKind> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
|
|||
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() {
|
||||
|
|
|
|||
|
|
@ -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, DeskStamp, 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};
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ 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,
|
||||
};
|
||||
|
|
@ -161,6 +161,8 @@ pub enum PipelineFacts {
|
|||
Memory { label: Label, sort: MemorySort, about: Vec<Short>, 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 outcome's variant name; which answer counts now is `counted_answer`'s, derived.
|
||||
DeskAnswer { fork: Short, sequence: u32, outcome: Short, authority: Option<DeskAuthority> },
|
||||
}
|
||||
|
||||
impl PipelineFacts {
|
||||
|
|
@ -293,6 +295,12 @@ 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()),
|
||||
authority: value.authority,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1812,6 +1820,20 @@ 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: Some(eureka_pipeline::DeskAuthority::Decider) };
|
||||
let pick = || crate::desk::DeskAnswerRequest {
|
||||
fork: s(&q2),
|
||||
options_hash: crate::desk::options_hash(&shown),
|
||||
body: crate::desk::DeskBody::Answer { choice: Some(l("A")), note: None },
|
||||
answered_at: s("2026-11-10T12:00:00Z"),
|
||||
};
|
||||
let (answer_1, answer_2) = (id("desk_answer", "Q2.n1"), id("desk_answer", "Q2.n2"));
|
||||
for _ in 0..2 {
|
||||
assert!(matches!(mind.answer_desk(stamp.clone(), pick(), 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 +1879,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() };
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ 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 },
|
||||
/// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.
|
||||
EmptyRepos { campaign: String },
|
||||
RepoNotInCampaign { repo: String },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
|
|
@ -445,7 +445,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 +466,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 +479,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 +503,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())
|
||||
|
|
|
|||
|
|
@ -395,6 +395,95 @@
|
|||
"type": "string",
|
||||
"maxLength": 10
|
||||
},
|
||||
"DeskAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Decider"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Refused": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"$ref": "#/$defs/DeskRefusal"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Refused"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"DeskRefusal": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"StaleFork",
|
||||
"NotADecider",
|
||||
"ForkClosed",
|
||||
"ChoiceInvalid",
|
||||
"BadRetraction",
|
||||
"AlreadyRetracted"
|
||||
]
|
||||
},
|
||||
"Deviation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1091,6 +1180,74 @@
|
|||
"questions"
|
||||
]
|
||||
},
|
||||
"PipelineDeskAnswer": {
|
||||
"description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` are stamped by the Mind from the verified caller and\nare absent from the request type; `answered_at` is the client's claim kept as\nwritten, display only: order is `sequence`, the receiver's order. `sequence` is\nper question and is the last part of the key (the answer id).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"authority": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"campaign": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"choice": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Label"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fork": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Para"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/Sha256Hex"
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/DeskOutcome"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"campaign",
|
||||
"fork",
|
||||
"sequence",
|
||||
"options_hash",
|
||||
"answered_at",
|
||||
"answerer",
|
||||
"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 +1570,22 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_answer"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/$defs/PipelineDeskAnswer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1679,7 +1852,8 @@
|
|||
"Research",
|
||||
"Idea",
|
||||
"Reply",
|
||||
"Thought"
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
]
|
||||
},
|
||||
"PipelineMemory": {
|
||||
|
|
|
|||
|
|
@ -263,7 +263,9 @@
|
|||
"toward",
|
||||
"run",
|
||||
"reads",
|
||||
"day_through"
|
||||
"day_through",
|
||||
"fork",
|
||||
"answer"
|
||||
]
|
||||
},
|
||||
"ClaimOutcome": {
|
||||
|
|
@ -402,6 +404,95 @@
|
|||
"manifest"
|
||||
]
|
||||
},
|
||||
"DeskAuthority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Operator",
|
||||
"Decider"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Refused": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"$ref": "#/$defs/DeskRefusal"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Refused"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"DeskRefusal": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"StaleFork",
|
||||
"NotADecider",
|
||||
"ForkClosed",
|
||||
"ChoiceInvalid",
|
||||
"BadRetraction",
|
||||
"AlreadyRetracted"
|
||||
]
|
||||
},
|
||||
"Deviation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1400,6 +1491,48 @@
|
|||
"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": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.",
|
||||
"type": "object",
|
||||
|
|
@ -2529,6 +2662,74 @@
|
|||
"questions"
|
||||
]
|
||||
},
|
||||
"PipelineDeskAnswer": {
|
||||
"description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` are stamped by the Mind from the verified caller and\nare absent from the request type; `answered_at` is the client's claim kept as\nwritten, display only: order is `sequence`, the receiver's order. `sequence` is\nper question and is the last part of the key (the answer id).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"authority": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"campaign": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"choice": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Label"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fork": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Para"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/Sha256Hex"
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/DeskOutcome"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"campaign",
|
||||
"fork",
|
||||
"sequence",
|
||||
"options_hash",
|
||||
"answered_at",
|
||||
"answerer",
|
||||
"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 +3052,22 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_answer"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/$defs/PipelineDeskAnswer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -3781,6 +3998,51 @@
|
|||
"kind",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "`outcome` is the stored outcome's variant name; which answer counts now is `counted_answer`'s, derived.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "desk_answer"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authority": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DeskAuthority"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fork": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fork",
|
||||
"sequence",
|
||||
"outcome"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -4047,7 +4309,8 @@
|
|||
"Research",
|
||||
"Idea",
|
||||
"Reply",
|
||||
"Thought"
|
||||
"Thought",
|
||||
"DeskAnswer"
|
||||
]
|
||||
},
|
||||
"PipelineMemory": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue