diff --git a/README.md b/README.md index 17ea65f..b99689c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,14 @@ A Cargo workspace of three crates: `huginn-mind` is live: it opens one instance's store (an owned redb CultCache at `/minds//mind.redb`, locked for the mind's lifetime), and refuses a store whose `instance` document names another instance, whose -epoch record is foreign, or whose types are not a mind's. Document shape and -keys come from `epiphany-pipeline`; the store is CultLib's Rust CultCache. +epoch record is foreign, or whose types are not a mind's. It admits batches +of pipeline documents through one commit path: the leaf's bounds, formats and +keys, then the organ's cross-document rules (references, in-force status, +the resolution matrix, derived resolutions and stewardships), then one +compare-and-swap that lands the batch whole with a receipt naming the exact +bytes it read and wrote. An exact replay answers with the stored receipt. +Document shape and keys come from `epiphany-pipeline`; the store is CultLib's +Rust CultCache. `huginn-daemon` and `eureka-state` are stubs; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what each crate must do next. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs new file mode 100644 index 0000000..9c60ff2 --- /dev/null +++ b/crates/huginn-mind/src/admission.rs @@ -0,0 +1,1340 @@ +//! Admission: the single door into a mind. +//! +//! `admit` prepares typed documents through the leaf and calls +//! `admit_prepared`, the one commit path for Cut 8, Cut 10's sink, Cut 12's +//! hand-off and import, and Cut 13's tools. The steps run in order and the +//! first failing step is the outcome: +//! +//! - A1 the batch names this mind; A2 one to sixty-four envelopes; +//! - A3 every envelope passes the leaf's bounds, formats and key +//! recomputation; A4 identities are unique in the batch; A5 every document +//! carrying an instance names this mind; +//! - A6 an empty mind's first batch carries exactly one `instance` document, +//! and a batch carrying one derives the epoch record; +//! - A7 every reference names a document present in the image or the batch, +//! looked up by `(kind, id)`; the ones resolved in the image become the +//! receipt's strong reads; +//! - the derived writes (a ruling's `Answered` resolution, a hand-off's +//! stewardship withdrawal or assignment) are appended and pass A3-A7; +//! - A8 the per-kind rules, below; A9 an exact replay answers with the stored +//! receipt, after validation and never before it (ruling 20); A10 no write +//! collides with the image; A11 the commit. +//! +//! Every cross-field and cross-document rule lives here and nowhere else: +//! the leaf never gains one, the daemon and the client never re-derive one. +//! "In force" means no resolution names the document in image or batch; it +//! is computed at rule time and never stored. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + ClaimOutcome, FindingConfidence, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, + PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, + validate_pipeline_write_envelope, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::mind::{HuginnMindEpoch, Mind, unavailable}; +use crate::receipt::{self, CommitOutcome, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The most envelopes one batch may carry. +pub const BATCH_MAX: usize = 64; + +/// One admission request: the mind it is for, who asks, and the documents. +/// The leaf's `PipelineDocument` derives neither `Serialize` nor +/// `JsonSchema` at the pinned rev, so this type cannot either; the wire +/// shape is Cut 10's. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PipelineAdmissionBatch { + pub instance: Slug, + pub provenance: PipelineProvenance, + pub documents: Vec, +} + +/// How admission ended. `Committed.writes` names every document the batch +/// landed, derived writes included; Cut 11 indexes from it after `admit` +/// returns, and never inside it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineAdmissionOutcome { + Committed { receipt_id: String, committed_at: String, writes: Vec }, + AlreadyAdmitted { receipt_id: String }, + Refused(MindRefusal), + Conflict { identities: Vec }, +} + +impl Mind { + /// Validates and prepares every document through the leaf, then admits + /// the envelopes. + pub fn admit(&mut self, batch: PipelineAdmissionBatch, now: DateTime) -> PipelineAdmissionOutcome { + let mut envelopes = Vec::with_capacity(batch.documents.len()); + for document in &batch.documents { + if let Err(refusal) = document.validate() { + return PipelineAdmissionOutcome::Refused(MindRefusal::Document(refusal)); + } + match document.prepare(self.cache()) { + Ok(envelope) => envelopes.push(envelope), + Err(error) => return PipelineAdmissionOutcome::Refused(unavailable(error)), + } + } + self.admit_prepared(&batch.instance, batch.provenance, envelopes, now) + } + + /// The single commit path: envelopes prepared by anyone, validated here + /// whoever prepared them. + pub fn admit_prepared( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> PipelineAdmissionOutcome { + match self.admit_steps(instance, provenance, envelopes, now) { + Ok(outcome) => outcome, + Err(refusal) => PipelineAdmissionOutcome::Refused(refusal), + } + } + + fn admit_steps( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> Result { + // A1 + if instance != self.instance() { + return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: self.instance().0.clone() }); + } + // A2 + if envelopes.is_empty() || envelopes.len() > BATCH_MAX { + return Err(MindRefusal::BatchSize { actual: envelopes.len() as u32 }); + } + let mind = self.instance().clone(); + let mut docs = Docs::from_image(self.envelopes())?; + // A3, A5, then A4 on push. + for envelope in envelopes { + docs.push(stage(envelope, &mind)?)?; + } + // A6 + let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; + if self.is_empty() && !carries_instance { + return Err(MindRefusal::MissingIdentity); + } + // A7 + let mut strong = BTreeSet::new(); + for staged in &docs.batch { + resolve(&docs, staged, &mind, &mut strong)?; + } + // Derived writes pass A3-A7 themselves. + let originals = docs.batch.len(); + for document in derive(&docs, &mind) { + document.validate()?; + let envelope = document.prepare(self.cache()).map_err(unavailable)?; + docs.push(stage(envelope, &mind)?)?; + } + for staged in &docs.batch[originals..] { + resolve(&docs, staged, &mind, &mut strong)?; + } + // The pins: cited image bytes, and every write. + let strong_reads = strong + .iter() + .filter_map(|(type_id, key)| self.raw_envelope(type_id, key).cloned()) + .collect::>(); + let mut writes = docs.batch.iter().map(|staged| staged.envelope.clone()).collect::>(); + if carries_instance { + writes.push(HuginnMindEpoch::envelope(self.cache())?); + } + let candidate = receipt::candidate(&mind, provenance, &strong_reads, &writes, now)?; + // A8 + for staged in &docs.batch { + check(&docs, staged, &mind)?; + } + // A9 + if let Some(existing) = receipt::replay(self, &candidate)? { + return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); + } + // A10 + refuse_collisions(&docs)?; + // A11 + let landed = docs.batch.iter().map(|staged| PipelineRef { kind: staged.kind, id: Short(staged.key.clone()) }).collect(); + match receipt::commit(self, candidate, strong_reads, writes)? { + CommitOutcome::Committed(receipt) => Ok(PipelineAdmissionOutcome::Committed { + receipt_id: receipt.receipt_id, + committed_at: receipt.committed_at, + writes: landed, + }), + CommitOutcome::Conflict(identities) => Ok(PipelineAdmissionOutcome::Conflict { identities }), + } + } +} + +/// A batch document after A3 and A5: decoded, keyed, and its envelope. +struct Staged { + kind: PipelineKind, + key: String, + document: PipelineDocument, + envelope: CultCacheEnvelope, +} + +/// An image document, decoded. +struct Held { + kind: PipelineKind, + key: String, + document: PipelineDocument, +} + +/// The image and the batch, the two places a rule may look. +struct Docs { + image: Vec, + batch: Vec, +} + +fn kind_of_type(type_id: &str) -> Option { + PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == type_id) +} + +/// The kind segment of a full id, for the one field typed `Short` whose +/// referent may be of any kind. +fn kind_of_id(id: &str) -> Option { + let name = id.split(':').nth(1)?; + PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) +} + +impl Docs { + fn from_image(envelopes: &[CultCacheEnvelope]) -> Result { + let mut image = Vec::new(); + for envelope in envelopes { + let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; + let document = PipelineDocument::decode(envelope).map_err(|refusal| MindRefusal::Unavailable { + detail: format!("stored document {} does not decode: {refusal}", envelope.key), + })?; + image.push(Held { kind, key: envelope.key.clone(), document }); + } + Ok(Self { image, batch: Vec::new() }) + } + + /// A4: one identity, once. + fn push(&mut self, staged: Staged) -> Result<(), MindRefusal> { + if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); + } + self.batch.push(staged); + Ok(()) + } + + fn in_batch(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) + } + + fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.image.iter().find(|held| held.kind == kind && held.key == id).map(|held| &held.document) + } + + fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) + } + + fn of_kind(&self, kind: PipelineKind) -> impl Iterator { + let batch = self.batch.iter().filter(move |staged| staged.kind == kind).map(|staged| (staged.key.as_str(), &staged.document)); + let image = self.image.iter().filter(move |held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)); + batch.chain(image) + } + + fn resolutions(&self) -> impl Iterator { + self.of_kind(PipelineKind::Resolution).filter_map(|(_, document)| match document { + PipelineDocument::Resolution(resolution) => Some(resolution), + _ => None, + }) + } + + /// No resolution in image or batch names the document. + fn in_force(&self, kind: PipelineKind, id: &str) -> bool { + self.in_force_unless(kind, id, |_| false) + } + + /// In force, ignoring a resolution the caller's own document derives: + /// the ruling that answers a question is what resolves it, and the + /// hand-off that withdraws a stewardship is what resolves that. + fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&ResolutionOutcome) -> bool) -> bool { + !self + .resolutions() + .any(|resolution| resolution.subject.kind == kind && resolution.subject.id.0 == id && !own(&resolution.outcome)) + } + + /// The in-force stewardship of `(mind, repo)`, if any, ignoring the + /// withdrawal `hand_off_key` derives. + fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> { + self.of_kind(PipelineKind::Stewardship).find_map(|(key, document)| match document { + PipelineDocument::Stewardship(stewardship) + if stewardship.instance == *mind + && stewardship.repo == *repo + && self.in_force_unless(PipelineKind::Stewardship, key, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) => + { + Some((key, stewardship)) + } + _ => None, + }) + } +} + +/// A3 through the leaf, then A5. +fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { + validate_pipeline_write_envelope(&envelope)?; + let document = PipelineDocument::decode(&envelope)?; + refuse_foreign_instance(&document, mind)?; + Ok(Staged { kind: document.kind(), key: envelope.key.clone(), document, envelope }) +} + +/// A5: every document carrying an instance field names this mind; a hand-off +/// names it on one side. +fn refuse_foreign_instance(document: &PipelineDocument, mind: &Slug) -> Result<(), MindRefusal> { + let foreign = |declared: &Slug| MindRefusal::ForeignInstance { declared: declared.0.clone(), mind: mind.0.clone() }; + match document { + PipelineDocument::Instance(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::Stewardship(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::HandOff(value) if value.from_instance != *mind && value.to_instance != *mind => { + Err(foreign(&value.from_instance)) + } + _ => Ok(()), + } +} + +/// What a missing referent is called, by the field that cited it. +enum Missing { + Reference, + SpecOfReport(String), + Supersessor, +} + +struct Reference { + kind: PipelineKind, + id: String, + missing: Missing, +} + +fn reference(kind: PipelineKind, id: &str) -> Reference { + Reference { kind, id: id.to_string(), missing: Missing::Reference } +} + +fn referenced(pipeline_ref: &PipelineRef) -> Reference { + reference(pipeline_ref.kind, &pipeline_ref.id.0) +} + +fn outcome_references(outcome: &ResolutionOutcome) -> Vec { + match outcome { + ResolutionOutcome::Superseded { by } => by + .iter() + .map(|supersessor| Reference { kind: supersessor.kind, id: supersessor.id.0.clone(), missing: Missing::Supersessor }) + .collect(), + ResolutionOutcome::Answered { by } => vec![referenced(by)], + ResolutionOutcome::Fixed { by: Some(by), .. } => vec![referenced(by)], + ResolutionOutcome::Deferred { to } => vec![referenced(to)], + ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { + vec![] + } + } +} + +/// A7's field list: every `PipelineRef` and every full-id `Short` field. +/// `Fixed.commit` and `ForeignRef` are syntax only. A hand-off's documents +/// are cited on the source side only; the receiving side imports them +/// afterwards (Cut 12). +fn references(staged: &Staged, mind: &Slug) -> Result, MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let mut refs = Vec::new(); + match &staged.document { + D::Question(question) => refs.extend(question.raised_in.as_ref().map(referenced)), + D::Ruling(ruling) => refs.extend(ruling.answers.as_ref().map(|question| reference(K::Question, &question.0))), + D::CutSpec(spec) => { + refs.extend(spec.rulings.iter().map(|ruling| reference(K::Ruling, &ruling.0))); + refs.extend(spec.questions.iter().map(|question| reference(K::Question, &question.0))); + } + D::CutReport(report) => { + refs.push(Reference { + kind: K::CutSpec, + id: report.cut_spec.0.clone(), + missing: Missing::SpecOfReport(staged.key.clone()), + }); + refs.extend(report.forks.iter().map(|question| reference(K::Question, &question.0))); + } + D::Verdict(verdict) => { + refs.push(reference(K::CutReport, &verdict.cut_report.0)); + refs.extend(verdict.claims.iter().flat_map(|claim| claim.findings.iter()).map(|finding| reference(K::Finding, &finding.0))); + } + D::Finding(finding) => refs.push(reference(K::Verdict, &finding.verdict.0)), + D::FollowUp(follow_up) => refs.push(referenced(&follow_up.source)), + D::Resolution(resolution) => { + refs.push(referenced(&resolution.subject)); + refs.extend(outcome_references(&resolution.outcome)); + } + D::HandOff(hand_off) if hand_off.from_instance == *mind => { + for document in &hand_off.documents { + let kind = kind_of_id(&document.0).ok_or_else(|| PipelineRefusal::InvalidFormat { + field: "hand_off.documents".into(), + value: document.0.clone(), + })?; + refs.push(reference(kind, &document.0)); + } + } + D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::HandOff(_) => {} + } + Ok(refs) +} + +/// A7: every referent is in the batch or the image; the image ones are the +/// receipt's strong reads. +fn resolve(docs: &Docs, staged: &Staged, mind: &Slug, strong: &mut BTreeSet<(String, String)>) -> Result<(), MindRefusal> { + for reference in references(staged, mind)? { + if docs.in_batch(reference.kind, &reference.id).is_some() { + continue; + } + if docs.in_image(reference.kind, &reference.id).is_some() { + strong.insert((reference.kind.type_id().to_string(), reference.id)); + continue; + } + return Err(match reference.missing { + Missing::Reference => MindRefusal::MissingReference { kind: reference.kind, id: reference.id }, + Missing::SpecOfReport(report) => MindRefusal::CutReportWithoutSpec { report }, + Missing::Supersessor => MindRefusal::UnknownSupersessor { id: reference.id }, + }); + } + Ok(()) +} + +/// The writes admission derives from the batch: a ruling that answers +/// derives the `Answered` resolution of its question; a hand-off derives +/// the stewardship withdrawal on its source side and the stewardship +/// assignment on its receiving side. One the batch already carries is not +/// derived again. +fn derive(docs: &Docs, mind: &Slug) -> Vec { + use PipelineDocument as D; + use PipelineKind as K; + let mut derived = Vec::new(); + for staged in &docs.batch { + match &staged.document { + D::Ruling(ruling) => { + if let Some(question) = &ruling.answers { + derived.push(D::Resolution(PipelineResolution { + subject: PipelineRef { kind: K::Question, id: question.clone() }, + outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } }, + rationale: ruling.ruling.clone(), + resolved_on: ruling.ruled_on.clone(), + })); + } + } + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind + && let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key)) + { + derived.push(D::Resolution(PipelineResolution { + subject: PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }, + outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) }, + rationale: hand_off.reason.clone(), + resolved_on: hand_off.handed_on.clone(), + })); + } + if hand_off.to_instance == *mind { + derived.push(D::Stewardship(PipelineStewardship { + instance: mind.clone(), + repo: hand_off.repo.clone(), + assigned_on: hand_off.handed_on.clone(), + note: Line(staged.key.clone()), + })); + } + } + _ => {} + } + } + derived.retain(|document| !docs.batch.iter().any(|staged| staged.document == *document)); + derived +} + +/// A8: the per-kind rules. +fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let key = staged.key.as_str(); + match &staged.document { + D::Campaign(campaign) => { + if campaign.repos.is_empty() { + return Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 1, actual: 0 }.into()); + } + for repo in &campaign.repos { + if docs.stewardship_of(mind, repo, None).is_none() { + return Err(MindRefusal::RepoNotStewarded { repo: repo.0.clone() }); + } + } + Ok(()) + } + D::Target(target) => { + let predecessor = D::Target(epiphany_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() }); + revision_rule(docs, K::Target, key, target.revision, &predecessor)?; + unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str())) + } + D::Question(question) => { + if question.options.len() < 2 || !question.options.iter().any(|option| option.label == question.recommended) { + return Err(MindRefusal::InvalidOptions { question: key.into() }); + } + unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str())) + } + D::Ruling(ruling) => { + if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { + return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); + } + let Some(question_id) = &ruling.answers else { return Ok(()) }; + let Some(D::Question(question)) = docs.find(K::Question, &question_id.0) else { + return Err(MindRefusal::MissingReference { kind: K::Question, id: question_id.0.clone() }); + }; + let own = |outcome: &ResolutionOutcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) + }; + if !docs.in_force_unless(K::Question, &question_id.0, own) { + return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() }); + } + let choice = ruling.choice.as_ref().map_or("", |choice| choice.0.as_str()); + if !question.options.iter().any(|option| option.label.0 == choice) { + return Err(MindRefusal::InvalidChoice { ruling: key.into(), choice: choice.into() }); + } + Ok(()) + } + D::CutSpec(spec) => { + let campaign = docs.of_kind(K::Campaign).find_map(|(_, document)| match document { + D::Campaign(campaign) if campaign.slug == spec.campaign => Some(campaign), + _ => None, + }); + let Some(campaign) = campaign else { + return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() }); + }; + if !campaign.repos.contains(&spec.repo) { + return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); + } + for ruling in &spec.rulings { + if !docs.in_force(K::Ruling, &ruling.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); + } + } + let predecessor = D::CutSpec(epiphany_pipeline::PipelineCutSpec { revision: spec.revision.wrapping_sub(1), ..spec.clone() }); + revision_rule(docs, K::CutSpec, key, spec.revision, &predecessor) + } + D::CutReport(report) => { + let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &report.cut_spec.0) else { + return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); + }; + if !docs.in_force(K::CutSpec, &report.cut_spec.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); + } + if report.repo != spec.repo { + return Err(MindRefusal::SpecMismatch { field: "repo".into() }); + } + if report.branch != spec.branch { + return Err(MindRefusal::SpecMismatch { field: "branch".into() }); + } + if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); + } + Ok(()) + } + D::Verdict(verdict) => { + let Some(D::CutReport(report)) = docs.find(K::CutReport, &verdict.cut_report.0) else { + return Err(MindRefusal::MissingReference { kind: K::CutReport, id: verdict.cut_report.0.clone() }); + }; + for claim in &verdict.claims { + let confirmed = claim.findings.iter().filter_map(|finding| docs.find(K::Finding, &finding.0)).any(|document| { + matches!(document, D::Finding(finding) if finding.confidence == FindingConfidence::Confirmed) + }); + match claim.outcome { + ClaimOutcome::Falsified if !confirmed => { + return Err(MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: claim.claim.0.clone() }); + } + ClaimOutcome::Unproven if confirmed => { + return Err(MindRefusal::UnprovenClaimWithConfirmedFinding { claim: claim.claim.0.clone() }); + } + _ => {} + } + for label in &claim.mutations { + if !report.mutations.iter().any(|mutation| mutation.label == *label) { + return Err(MindRefusal::UnknownMutationLabel { label: label.0.clone() }); + } + } + } + for promise in &report.promises { + let count = verdict.claims.iter().filter(|claim| claim.promise.as_ref() == Some(&promise.label)).count(); + if count != 1 { + return Err(MindRefusal::PromiseWithoutVerdict { promise: promise.label.0.clone() }); + } + } + Ok(()) + } + D::Finding(finding) => { + if finding.evidence.is_empty() || finding.locations.is_empty() { + return Err(MindRefusal::FindingWithoutEvidence); + } + let target = docs.of_kind(K::Target).find_map(|(target_key, document)| match document { + D::Target(target) if target.campaign == finding.campaign && docs.in_force(K::Target, target_key) => Some(target), + _ => None, + }); + for label in &finding.invariants { + if !target.is_some_and(|target| target.invariants.iter().any(|invariant| invariant.label == *label)) { + return Err(MindRefusal::UnknownInvariant { label: label.0.clone() }); + } + } + Ok(()) + } + D::Resolution(resolution) => resolution_rule(docs, resolution), + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind && docs.stewardship_of(mind, &hand_off.repo, Some(key)).is_none() { + return Err(MindRefusal::NotStewarded { repo: hand_off.repo.0.clone() }); + } + Ok(()) + } + D::FollowUp(_) | D::Instance(_) | D::Stewardship(_) => Ok(()), + } +} + +/// Revision 1 stands alone; revision N needs a resolution in the batch that +/// supersedes revision N-1 by this document. The predecessor's key is the +/// leaf's, from the same document one revision back. +fn revision_rule(docs: &Docs, kind: PipelineKind, key: &str, revision: u32, predecessor: &PipelineDocument) -> Result<(), MindRefusal> { + if revision == 1 { + return Ok(()); + } + let refusal = || MindRefusal::RevisionWithoutSupersession { kind, revision }; + if revision == 0 { + return Err(refusal()); + } + let predecessor_key = pipeline_key(predecessor)?; + let superseded = docs.batch.iter().any(|staged| { + matches!(&staged.document, PipelineDocument::Resolution(resolution) + if resolution.subject.kind == kind + && resolution.subject.id.0 == predecessor_key + && matches!(&resolution.outcome, ResolutionOutcome::Superseded { by } + if by.iter().any(|supersessor| supersessor.kind == kind && supersessor.id.0 == key))) + }); + if superseded { Ok(()) } else { Err(refusal()) } +} + +fn unique_labels<'a>(field: &str, labels: impl Iterator) -> Result<(), MindRefusal> { + let mut seen = BTreeSet::new(); + for label in labels { + if !seen.insert(label) { + return Err(MindRefusal::DuplicateLabel { field: field.into(), label: label.into() }); + } + } + Ok(()) +} + +fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { + match outcome { + ResolutionOutcome::Superseded { .. } => "Superseded", + ResolutionOutcome::Answered { .. } => "Answered", + ResolutionOutcome::Fixed { .. } => "Fixed", + ResolutionOutcome::Deferred { .. } => "Deferred", + ResolutionOutcome::Recorded { .. } => "Recorded", + ResolutionOutcome::Withdrawn { .. } => "Withdrawn", + } +} + +/// The resolution matrix: which outcomes a subject kind admits, and of which +/// kinds its referents must be. Admission's, never the leaf's. +fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { + use PipelineKind as K; + use ResolutionOutcome as O; + let all = |by: &[PipelineRef], kind: K| by.iter().all(|supersessor| supersessor.kind == kind); + let fits = match (subject, outcome) { + (K::Target, O::Superseded { by }) => all(by, K::Target), + (K::Question, O::Answered { by }) => by.kind == K::Ruling, + (K::Question, O::Withdrawn { .. }) => true, + (K::Ruling, O::Superseded { by }) => all(by, K::Ruling), + (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), + (K::CutSpec, O::Withdrawn { .. }) => true, + (K::Finding, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::Finding, O::Deferred { to }) => to.kind == K::FollowUp, + (K::Finding, O::Recorded { .. } | O::Withdrawn { .. }) => true, + (K::FollowUp, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::FollowUp, O::Superseded { by }) => all(by, K::FollowUp), + (K::FollowUp, O::Withdrawn { .. }) => true, + (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), + (K::Stewardship, O::Withdrawn { .. }) => true, + (K::Resolution, O::Withdrawn { .. }) => true, + _ => false, + }; + fits +} + +/// The resolution row: the matrix, a non-empty supersession, every referent +/// in force, and the two coherence rules (an `Answered` ruling answers this +/// question; a cut spec is superseded within its cut). +fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let subject_kind = resolution.subject.kind; + let subject_id = resolution.subject.id.0.as_str(); + if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() { + return Err(MindRefusal::EmptySupersession); + } + let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() }; + if !matrix(subject_kind, &resolution.outcome) { + return Err(incompatible()); + } + for referent in outcome_references(&resolution.outcome) { + if !docs.in_force(referent.kind, &referent.id) { + return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id }); + } + } + match &resolution.outcome { + ResolutionOutcome::Superseded { by } if subject_kind == K::CutSpec => { + let Some(D::CutSpec(subject)) = docs.find(K::CutSpec, subject_id) else { return Ok(()) }; + for supersessor in by { + if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0) + && spec.cut != subject.cut + { + return Err(incompatible()); + } + } + } + ResolutionOutcome::Answered { by } => { + let Some(D::Ruling(ruling)) = docs.find(K::Ruling, &by.id.0) else { return Ok(()) }; + if ruling.answers.as_ref().map(|answers| answers.0.as_str()) != Some(subject_id) { + return Err(incompatible()); + } + } + _ => {} + } + Ok(()) +} + +/// A10: a write whose identity the image already holds. A resolution's key +/// is outcome-invariant, so its collision means the subject is already +/// resolved. +fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { + for staged in &docs.batch { + if docs.in_image(staged.kind, &staged.key).is_some() { + return Err(match &staged.document { + PipelineDocument::Resolution(resolution) => MindRefusal::AlreadyResolved { subject: resolution.subject.id.0.clone() }, + _ => MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() }, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::receipt::{DocumentVersion, Faculty, HuginnCommitReceipt}; + use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; + use cultcache_rs::DatabaseEntry; + use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument as D, PipelineKind as K}; + use sha2::{Digest, Sha256}; + + fn receipt_count(mind: &Mind) -> usize { + mind.receipts().unwrap().len() + } + + #[test] + fn an_empty_store_opens_and_the_first_write_must_carry_the_instance() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + assert!(mind.is_empty()); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(INSTANCE, REPO), campaign(&[REPO])])), MindRefusal::MissingIdentity); + assert!(store.rows().is_empty()); + let (receipt_id, writes) = committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + assert!(writes.contains(&r(K::Instance, &format!("{INSTANCE}:instance:self")))); + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].receipt_id, receipt_id); + let identities = receipts[0].writes.iter().map(DocumentVersion::identity).collect::>(); + assert!(identities.contains(&(K::Instance.type_id(), format!("{INSTANCE}:instance:self").as_str()))); + assert!(identities.contains(&(HuginnMindEpoch::TYPE, PIPELINE_SCHEMA_EPOCH)), "the epoch record is derived: {identities:?}"); + assert!(receipts[0].strong_reads.is_empty()); + // The store now opens again as this instance, and as nothing else. + assert!(!Mind::open_with(store.clone(), &slug(INSTANCE)).unwrap().is_empty()); + assert!(Mind::open_with(store, &slug(OTHER_INSTANCE)).is_err()); + } + + #[test] + fn admission_refuses_a_foreign_instance_whatever_the_transport() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let foreign = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + assert_eq!( + refusal(mind.admit_prepared(&slug(OTHER_INSTANCE), provenance(Faculty::Hands), envelopes, now())), + foreign + ); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(OTHER_INSTANCE, OTHER_REPO)])), foreign); + assert_eq!( + refusal(admit(&mut mind, vec![hand_off("a", "b", REPO, &[])])), + MindRefusal::ForeignInstance { declared: "a".into(), mind: INSTANCE.into() } + ); + assert_eq!(refusal(admit(&mut mind, vec![instance(OTHER_INSTANCE)])), foreign); + assert_eq!(store.rows(), before); + } + + #[test] + fn two_minds_in_one_state_root_stay_separate() { + let root = tempfile::tempdir().unwrap(); + let mut yggdrasil = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let mut thought_cage = Mind::open(root.path(), &slug(OTHER_INSTANCE)).unwrap(); + assert_ne!(Mind::path_for(root.path(), &slug(INSTANCE)), Mind::path_for(root.path(), &slug(OTHER_INSTANCE))); + committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)])); + let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + assert!(yggdrasil.envelope(K::Stewardship, &key).is_some()); + assert!(thought_cage.envelope(K::Stewardship, &key).is_none()); + assert_eq!(yggdrasil.envelopes().len(), 4); + assert_eq!(thought_cage.envelopes().len(), 3); + } + + #[test] + fn keys_are_recomputed_and_a_forged_key_refuses() { + let mut mind = seeded(); + let mut envelope = prepare(&question("Q1", &["A", "B"], "A")); + let expected = envelope.key.clone(); + envelope.key = format!("{expected}-forged"); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), vec![envelope.clone()], now()); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: K::Question, key: envelope.key, expected }) + ); + } + + #[test] + fn references_must_exist_in_image_or_batch() { + let mut mind = seeded(); + let absent_question = id("question", "Q9"); + let mut answering = ruling("R1"); + answering.answers = Some(s(&absent_question)); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::MissingReference { kind: K::Question, id: absent_question } + ); + let absent_finding = id("finding", "cut-1.s1.F9"); + assert_eq!( + refusal(admit(&mut mind, vec![follow_up("FU-1", r(K::Finding, &absent_finding))])), + MindRefusal::MissingReference { kind: K::Finding, id: absent_finding } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])])])), + MindRefusal::MissingReference { kind: K::CutReport, id: id("cut_report", "cut-1.h1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])), + MindRefusal::MissingReference { kind: K::Question, id: id("question", "Q1") } + ); + // A well-formed id of another kind is a missing reference of the + // cited kind, not a wrong-kind refusal. + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("question", "Q1"))]; + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(spec)])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("question", "Q1") } + ); + } + + #[test] + fn batch_is_all_or_nothing() { + let store = RefusingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let receipts = receipt_count(&mind); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("go ahead".into()); + let batch = vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "B"), D::Ruling(quoted)]; + assert_eq!(refusal(admit(&mut mind, batch)), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Lose); + let outcome = admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Conflict { .. }), "{outcome:?}"); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Fail); + assert!(matches!(refusal(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])), MindRefusal::Unavailable { .. })); + assert_eq!(store.rows(), before); + + store.command(SwapCommand::Delegate); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + #[test] + fn exact_replay_returns_already_admitted_across_provenance() { + let mut mind = seeded(); + let receipts = receipt_count(&mind); + let (first, _) = committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Soul), + documents: vec![question("Q1", &["A", "B"], "A")], + }; + let later = now() + chrono::Duration::hours(1); + assert_eq!(mind.admit(batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first.clone() }); + assert_eq!(receipt_count(&mind), receipts + 1); + // The first write, epoch record and all, replays too. + let seed_batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Imagination), + documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])], + }; + assert!(matches!(mind.admit(seed_batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { .. })); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + #[test] + fn a_refused_batch_is_not_answered_from_a_stored_receipt() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let envelopes = vec![prepare(&D::CutSpec(spec))]; + committed(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes.clone(), now())); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R2")), + resolution(r(K::Ruling, &id("ruling", "R1")), superseded(&[r(K::Ruling, &id("ruling", "R2"))])), + ])); + assert_eq!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes, now())), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R1") } + ); + } + + #[test] + fn a_receipt_names_the_exact_bytes_it_read_and_wrote() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let cited = mind.envelope(K::Ruling, &id("ruling", "R1")).unwrap().clone(); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(spec)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited)]); + let written = mind.envelope(K::CutSpec, &id("cut_spec", "cut-1.r1")).unwrap(); + assert_eq!(receipt.writes, vec![DocumentVersion::from_envelope(written)]); + for version in receipt.strong_reads.iter().chain(&receipt.writes) { + assert_eq!(version.payload_sha256, format!("{:x}", Sha256::digest(&version.payload_msgpack))); + } + let digest = rmp_serde::to_vec_named(&(&receipt.instance, &receipt.strong_reads, &receipt.writes)).unwrap(); + assert_eq!(receipt_id, format!("mind-commit-{:x}", Sha256::digest(&digest))); + + // The same content admitted by another faculty in another mind has + // the same id: provenance is not digested. + let mut twin = opened(MemoryStore::new(), INSTANCE); + seed(&mut twin); + committed(admit(&mut twin, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance: provenance(Faculty::Soul), documents: vec![D::CutSpec(spec)] }; + let (twin_id, _) = committed(twin.admit(batch, now() + chrono::Duration::days(1))); + assert_eq!(twin_id, receipt_id); + let twin_receipt = twin.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == twin_id).unwrap(); + assert_ne!(twin_receipt.provenance, receipt.provenance); + } + + #[test] + fn a_document_is_written_once_and_superseded_by_resolution() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R8"))])); + let mut rewritten = ruling("R8"); + rewritten.ruling = "A repo owns its mind.".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(rewritten)])), + MindRefusal::IdentityCollision { kind: K::Ruling, id: id("ruling", "R8") } + ); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R9")), + resolution(r(K::Ruling, &id("ruling", "R8")), superseded(&[r(K::Ruling, &id("ruling", "R9"))])), + ])); + assert_eq!(mind.get(K::Ruling, &id("ruling", "R8")).unwrap(), Some(D::Ruling(ruling("R8")))); + } + + #[test] + fn subject_resolves_at_most_once() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + } + + /// A seeded mind with one document of every resolvable kind, and the + /// non-resolvable ones beside them. + fn world() -> Mind { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + stewardship(INSTANCE, OTHER_REPO), + question("Q1", &["A", "B"], "A"), + D::Ruling(ruling("R1")), + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-1.s1.F1")], Some("P1"), &["M1"])]), + D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)), + follow_up("FU-1", r(K::Finding, &id("finding", "cut-1.s1.F1"))), + hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[]), + ])); + mind + } + + #[test] + fn the_resolution_matrix_is_admissions() { + let incompatible = |kind: K, outcome: &str| MindRefusal::IncompatibleResolution { subject_kind: kind, outcome: outcome.into() }; + let fixed = || ResolutionOutcome::Fixed { commit: sha(), by: None }; + let target_ref = r(K::Target, &id("target", "r1")); + let target_r2 = r(K::Target, &id("target", "r2")); + let question_ref = r(K::Question, &id("question", "Q1")); + let ruling_ref = r(K::Ruling, &id("ruling", "R1")); + let spec_ref = r(K::CutSpec, &id("cut_spec", "cut-1.r1")); + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let follow_up_ref = r(K::FollowUp, &id("follow_up", "FU-1")); + let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany")); + let campaign_ref = r(K::Campaign, &id("campaign", "self")); + let report_ref = r(K::CutReport, &id("cut_report", "cut-1.h1")); + let verdict_ref = r(K::Verdict, &id("verdict", "cut-1.s1")); + let instance_ref = r(K::Instance, &format!("{INSTANCE}:instance:self")); + let hand_off_ref = r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Huginn.2026-09-16")); + + // Accepted, one per resolvable kind. + committed(admit(&mut world(), vec![target(2, &[INVARIANT]), resolution(target_ref.clone(), superseded(&[target_r2.clone()]))])); + committed(admit(&mut world(), vec![resolution(question_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(ruling_ref.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])); + committed(admit(&mut world(), vec![resolution(spec_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Recorded { reason: "noted".into() })])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); + committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); + committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1")); + committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); + + // Refused, one per kind, and every non-resolvable kind. + assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref, withdrawn())])), incompatible(K::Target, "Withdrawn")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref, fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref, withdrawn())])), incompatible(K::Ruling, "Withdrawn")); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::CutSpec, "Answered") + ); + // A cut spec superseded across cuts is refused too. + let mut other_cut = cut_spec("2", 1); + other_cut.cut = l("2"); + assert_eq!( + refusal(admit(&mut world(), vec![D::CutSpec(other_cut), resolution(spec_ref, superseded(&[r(K::CutSpec, &id("cut_spec", "cut-2.r1"))]))])), + incompatible(K::CutSpec, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(finding_ref.clone(), superseded(&[finding_ref.clone()]))])), + incompatible(K::Finding, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(follow_up_ref, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::FollowUp, "Answered") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), + incompatible(K::Stewardship, "Recorded") + ); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), + incompatible(K::Resolution, "Superseded") + ); + for (subject, kind) in [(campaign_ref, K::Campaign), (report_ref, K::CutReport), (verdict_ref, K::Verdict), (instance_ref, K::Instance), (hand_off_ref, K::HandOff)] { + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject, withdrawn())])), incompatible(kind, "Withdrawn")); + } + } + + #[test] + fn supersession_names_each_supersessor_and_none_is_empty() { + let subject = r(K::Ruling, &id("ruling", "R1")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject.clone(), superseded(&[]))])), MindRefusal::EmptySupersession); + let (_, writes) = committed(admit(&mut world(), vec![ + D::Ruling(ruling("R2")), + D::Ruling(ruling("R3")), + resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R3"))])), + ])); + assert_eq!(writes.len(), 3); + assert_eq!( + refusal(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R4"))]))])), + MindRefusal::UnknownSupersessor { id: id("ruling", "R4") } + ); + let mut mind = world(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R2")), D::Ruling(ruling("R3")), resolution(r(K::Ruling, &id("ruling", "R2")), superseded(&[r(K::Ruling, &id("ruling", "R3"))]))])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject, superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R2") } + ); + } + + #[test] + fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("C")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering.clone())])), + MindRefusal::InvalidChoice { ruling: id("ruling", "R1"), choice: "C".into() } + ); + answering.choice = Some(l("A")); + let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1"))]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.writes.len(), 2); + let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1")).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); + let mut again = ruling("R2"); + again.answers = Some(s(&id("question", "Q1"))); + again.choice = Some(l("B")); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") }); + } + + #[test] + fn an_operator_quote_requires_operator_authority() { + let mut mind = seeded(); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("all recommendations, go ahead".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(quoted.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + quoted.authority = RulingAuthority::Operator; + committed(admit(&mut mind, vec![D::Ruling(quoted)])); + } + + #[test] + fn a_revision_requires_its_predecessors_supersession_in_the_batch() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![target(2, &[INVARIANT])])), + MindRefusal::RevisionWithoutSupersession { kind: K::Target, revision: 2 } + ); + committed(admit(&mut mind, vec![ + target(2, &[INVARIANT]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 2))])), + MindRefusal::RevisionWithoutSupersession { kind: K::CutSpec, revision: 2 } + ); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + } + + #[test] + fn a_cut_report_cites_an_in_force_spec_and_agrees_with_it() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CutReportWithoutSpec { report: id("cut_report", "cut-1.h1") } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + let mut report = cut_report("1", 1); + report.branch = s("another-branch"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha("abcdef0".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { head: "abcdef0".into() }); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + } + + #[test] + fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), D::CutReport(cut_report("1", 1))])); + let finding_id = id("finding", "cut-1.s1.F1"); + let confirmed = D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[], Some("P1"), &[])])])), + MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: "Falsified claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Unproven, &[&finding_id], Some("P1"), &[])]), + confirmed.clone(), + ])), + MindRefusal::UnprovenClaimWithConfirmedFinding { claim: "Unproven claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], None, &[])])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![ + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + ])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &["M9"])])])), + MindRefusal::UnknownMutationLabel { label: "M9".into() } + ); + committed(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&finding_id], Some("P1"), &["M1"])]), + confirmed, + ])); + } + + #[test] + fn a_finding_names_evidence_locations_and_known_invariants() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])]), + ])); + let mut bare = finding("1", 1, "F1", FindingConfidence::Plausible); + bare.evidence = vec![]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(bare)])), MindRefusal::FindingWithoutEvidence); + let mut unknown = finding("1", 1, "F1", FindingConfidence::Plausible); + unknown.invariants = vec![l("nope")]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); + + // A raw envelope without `range` cannot be built from the type, and + // it is the leaf's decode that refuses it, before any organ rule. + #[derive(serde::Serialize)] + struct Rangeless { + campaign: Slug, + verdict: Short, + label: epiphany_pipeline::Label, + confidence: FindingConfidence, + severity: epiphany_pipeline::FindingSeverity, + claim: Line, + invariants: Vec, + locations: Vec, + failure_scenario: epiphany_pipeline::Para, + evidence: Vec, + precedents: Vec, + origin: epiphany_pipeline::FindingOrigin, + } + let whole = finding("1", 1, "F1", FindingConfidence::Plausible); + let rangeless = Rangeless { + campaign: whole.campaign.clone(), + verdict: whole.verdict.clone(), + label: whole.label.clone(), + confidence: whole.confidence, + severity: whole.severity, + claim: whole.claim.clone(), + invariants: whole.invariants.clone(), + locations: whole.locations.clone(), + failure_scenario: whole.failure_scenario.clone(), + evidence: whole.evidence.clone(), + precedents: vec![], + origin: whole.origin, + }; + let mut envelope = prepare(&D::Finding(whole)); + envelope.payload = rmp_serde::to_vec_named(&(rangeless,)).unwrap(); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Soul), vec![envelope], now()); + assert!( + matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) if field == "payload") + ); + } + + #[test] + fn a_campaign_names_only_repos_this_mind_stewards() { + let mut mind = opened(MemoryStore::new(), INSTANCE); + assert_eq!( + refusal(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); + let mut spec = cut_spec("1", 1); + spec.repo = repo(OTHER_REPO); + assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + } + + #[test] + fn a_hand_off_derives_this_minds_side_only() { + let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-16"); + let campaign_key = id("campaign", "self"); + let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany"); + + let mut source = seeded(); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[])])), + MindRefusal::NotStewarded { repo: OTHER_REPO.into() } + ); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&id("ruling", "R1")])])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("ruling", "R1") } + ); + let (_, writes) = committed(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let withdrawal = id_of(&stewardship_key); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Resolution, &withdrawal)]); + let Some(D::Resolution(derived)) = source.get(K::Resolution, &withdrawal).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Withdrawn { reason: Line(key.clone()) }); + assert_eq!(derived.subject, r(K::Stewardship, &stewardship_key)); + + let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); + let (_, writes) = committed(admit(&mut receiving, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany"); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Stewardship, &assigned)]); + let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() }; + assert_eq!(derived.note, Line(key.clone())); + assert_eq!(derived.instance, slug(OTHER_INSTANCE)); + assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload)); + } + + /// The key of the resolution of a stewardship: the subject's root, then + /// its kind and local. + fn id_of(stewardship_key: &str) -> String { + let (root, rest) = stewardship_key.split_once(':').unwrap(); + format!("{root}:resolution:{}", rest.replace(':', ".")) + } + + /// The second construction site of a receipt, deliberately: a receipt + /// planted through the store must be found by replay and must match. + #[test] + fn a_planted_receipt_with_other_content_is_refused_not_replayed() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + let candidate = receipt::candidate(&slug(INSTANCE), provenance(Faculty::Hands), &[], &envelopes, now()).unwrap(); + let mut planted = HuginnCommitReceipt { writes: vec![], ..candidate.clone() }; + planted.writes = candidate.strong_reads.clone(); + planted.receipt_id = candidate.receipt_id.clone(); + store.plant(mind.cache().prepare_entry_named(&planted.receipt_id, &planted).unwrap().0); + let mut mind = opened(store, INSTANCE); + assert!(matches!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), envelopes, now())), + MindRefusal::Unavailable { .. } + )); + } +} diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index a21a097..0084b2e 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -1,12 +1,31 @@ //! Test fixtures shared by the modules' tests: one valid document of each -//! kind the tests need, prepared through the leaf against the organ's cache. +//! kind, prepared through the leaf against the organ's cache, and a seeded +//! mind to admit into. +use chrono::{DateTime, TimeZone, Utc}; use cultcache_rs::CultCacheEnvelope; -use epiphany_pipeline::{Date, PipelineDocument, PipelineInstance, Short, Slug}; +use epiphany_pipeline::{ + AuthorityMap, ClaimOutcome, CodeLocation, CommitRange, CutVerification, Date, DocRef, Evidence, EvidenceKind, + FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MutationRecord, OrgRepo, PipelineCampaign, + PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFinding, PipelineFollowUp, PipelineHandOff, + PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, + PipelineStewardship, PipelineTarget, PipelineVerdict, Promise, QuestionOption, ReportCommit, ResolutionOutcome, + RulingAuthority, Sha, Short, Slug, StructuralDelta, TargetInvariant, VerdictClaim, +}; -use crate::mind::{HuginnMindEpoch, schema_cache}; +use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome}; +use crate::mind::{HuginnMindEpoch, Mind, schema_cache}; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; +use crate::store::test_stores::MemoryStore; pub(crate) const INSTANCE: &str = "yggdrasil"; +pub(crate) const OTHER_INSTANCE: &str = "thought-cage"; +pub(crate) const CAMPAIGN: &str = "eureka-state"; +pub(crate) const REPO: &str = "GameCult/Epiphany"; +pub(crate) const OTHER_REPO: &str = "GameCult/Huginn"; +pub(crate) const INVARIANT: &str = "mind-admits"; pub(crate) fn slug(value: &str) -> Slug { value.into() @@ -16,10 +35,59 @@ pub(crate) fn s(value: &str) -> Short { value.into() } +pub(crate) fn l(value: &str) -> Label { + value.into() +} + +pub(crate) fn repo(value: &str) -> OrgRepo { + value.into() +} + pub(crate) fn date() -> Date { Date("2026-09-16".into()) } +pub(crate) fn sha() -> Sha { + Sha("5f98228d".into()) +} + +pub(crate) fn id(kind: &str, local: &str) -> String { + format!("{CAMPAIGN}:{kind}:{local}") +} + +pub(crate) fn r(kind: PipelineKind, id: &str) -> PipelineRef { + PipelineRef { kind, id: s(id) } +} + +fn range() -> CommitRange { + CommitRange { base: sha(), head: sha() } +} + +fn location() -> CodeLocation { + CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None } +} + +pub(crate) fn evidence() -> Evidence { + Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() } +} + +fn doc_ref() -> DocRef { + DocRef { path: s("notes/eureka-pipeline-state-target.md"), start_line: 1, end_line: 9, commit: sha() } +} + +fn delta() -> StructuralDelta { + StructuralDelta { + lines_added: 0, + lines_removed: 0, + dependencies_added: vec![], + dependencies_removed: vec![], + formats_added: vec![], + formats_removed: vec![], + targets_added: vec![], + targets_removed: vec![], + } +} + pub(crate) fn instance(name: &str) -> PipelineDocument { PipelineDocument::Instance(PipelineInstance { instance: slug(name), @@ -29,6 +97,205 @@ pub(crate) fn instance(name: &str) -> PipelineDocument { }) } +pub(crate) fn stewardship(instance: &str, repo_name: &str) -> PipelineDocument { + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(instance), + repo: repo(repo_name), + assigned_on: date(), + note: "assigned".into(), + }) +} + +pub(crate) fn campaign(repos: &[&str]) -> PipelineDocument { + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: s("Eureka pipeline state"), + repos: repos.iter().map(|name| repo(name)).collect(), + working_branch: s("codex/eureka-pipeline-state"), + target_doc: doc_ref(), + }) +} + +pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument { + PipelineDocument::Target(PipelineTarget { + campaign: slug(CAMPAIGN), + revision, + invariants: invariants + .iter() + .map(|label| TargetInvariant { label: l(label), statement: "Only admission writes.".into() }) + .collect(), + not_in_scope: vec![], + canonical_implementations: vec![], + doc: doc_ref(), + }) +} + +pub(crate) fn question(label: &str, options: &[&str], recommended: &str) -> PipelineDocument { + PipelineDocument::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), + label: l(label), + question: "Who owns the state?".into(), + options: options.iter().map(|option| QuestionOption { label: l(option), text: "an option".into() }).collect(), + recommended: l(recommended), + depends: vec![], + raised_in: None, + asked_on: date(), + }) +} + +pub(crate) fn ruling(label: &str) -> PipelineRuling { + PipelineRuling { + campaign: slug(CAMPAIGN), + label: l(label), + answers: None, + choice: None, + ruling: "An instance owns its mind.".into(), + operator_quote: None, + ruled_on: date(), + precedents: vec![], + authority: RulingAuthority::Standing, + } +} + +pub(crate) fn cut_spec(cut: &str, revision: u32) -> PipelineCutSpec { + PipelineCutSpec { + campaign: slug(CAMPAIGN), + cut: l(cut), + revision, + title: s("A cut"), + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + base: sha(), + depends_on: vec![], + first: vec![], + deletes: vec![], + keeps_moves: vec![], + adds: vec![], + file_changes: vec![], + authority_map: None::, + verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] }, + estimate: delta(), + rulings: vec![], + questions: vec![], + } +} + +pub(crate) fn cut_report(cut: &str, attempt: u32) -> PipelineCutReport { + PipelineCutReport { + campaign: slug(CAMPAIGN), + cut_spec: s(&id("cut_spec", &format!("cut-{cut}.r1"))), + attempt, + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + commits: vec![ReportCommit { sha: sha(), subject: "Land the cut".into(), builds: true }], + range: range(), + verification: vec![evidence()], + mutations: vec![MutationRecord { + label: l("M1"), + rule: "the rule".into(), + location: location(), + before: "before".into(), + after: "after".into(), + commit: sha(), + failed_as_expected: true, + }], + deviations: vec![], + forks: vec![], + structural_delta: delta(), + landed_names: vec![], + undone: vec![], + promises: vec![Promise { label: l("P1"), text: "One derived key per document.".into() }], + } +} + +pub(crate) fn claim(outcome: ClaimOutcome, findings: &[&str], promise: Option<&str>, mutations: &[&str]) -> VerdictClaim { + VerdictClaim { + claim: Line(format!("{outcome:?} claim")), + outcome, + evidence: vec![evidence()], + findings: findings.iter().map(|finding| s(finding)).collect(), + promise: promise.map(l), + mutations: mutations.iter().map(|label| l(label)).collect(), + } +} + +pub(crate) fn verdict(cut: &str, pass: u32, claims: Vec) -> PipelineDocument { + PipelineDocument::Verdict(PipelineVerdict { + campaign: slug(CAMPAIGN), + cut_report: s(&id("cut_report", &format!("cut-{cut}.h1"))), + pass, + range: range(), + claims, + }) +} + +pub(crate) fn finding(cut: &str, pass: u32, label: &str, confidence: FindingConfidence) -> PipelineFinding { + PipelineFinding { + campaign: slug(CAMPAIGN), + verdict: s(&id("verdict", &format!("cut-{cut}.s{pass}"))), + label: l(label), + range: range(), + confidence, + severity: FindingSeverity::High, + claim: "A dotted label composes two keys.".into(), + invariants: vec![l(INVARIANT)], + locations: vec![location()], + failure_scenario: "Two documents claim one key.".into(), + evidence: vec![evidence()], + precedents: vec![], + origin: FindingOrigin::Introduced, + } +} + +pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument { + PipelineDocument::FollowUp(PipelineFollowUp { + campaign: slug(CAMPAIGN), + label: l(label), + source, + repo: repo(REPO), + locations: vec![location()], + item: "Per-kind admission rules.".into(), + why_it_can_wait: "The organ owns admission.".into(), + owner: s("Hands"), + }) +} + +pub(crate) fn resolution(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument { + PipelineDocument::Resolution(PipelineResolution { + subject, + outcome, + rationale: "Resolved.".into(), + resolved_on: date(), + }) +} + +pub(crate) fn withdrawn() -> ResolutionOutcome { + ResolutionOutcome::Withdrawn { reason: "moot".into() } +} + +pub(crate) fn superseded(by: &[PipelineRef]) -> ResolutionOutcome { + ResolutionOutcome::Superseded { by: by.to_vec() } +} + +pub(crate) fn hand_off(from: &str, to: &str, repo_name: &str, documents: &[&str]) -> PipelineDocument { + PipelineDocument::HandOff(PipelineHandOff { + from_instance: slug(from), + to_instance: slug(to), + repo: repo(repo_name), + documents: documents.iter().map(|document| s(document)).collect(), + reason: "The workstation mind takes the campaign.".into(), + handed_on: date(), + }) +} + +pub(crate) fn provenance(faculty: Faculty) -> PipelineProvenance { + PipelineProvenance { faculty, agent: s("claude"), session: s("session-1"), tool: s("admit") } +} + +pub(crate) fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap() +} + /// The leaf's envelope for a document, keyed and encoded as the organ stores /// it. pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { @@ -40,3 +307,40 @@ pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { pub(crate) fn epoch() -> CultCacheEnvelope { HuginnMindEpoch::envelope(&schema_cache().unwrap()).unwrap() } + +pub(crate) fn opened(store: S, instance: &str) -> Mind { + Mind::open_with(store, &slug(instance)).unwrap() +} + +/// Admits as Hands at the fixed clock. +pub(crate) fn admit(mind: &mut Mind, documents: Vec) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents }; + mind.admit(batch, now()) +} + +/// The first write into a fresh yggdrasil mind: its identity, stewardship of +/// the campaign repo, the campaign and a first target with one invariant. +pub(crate) fn seed(mind: &mut Mind) { + let outcome = admit(mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); +} + +pub(crate) fn seeded() -> Mind { + let mut mind = opened(MemoryStore::new(), INSTANCE); + seed(&mut mind); + mind +} + +pub(crate) fn refusal(outcome: PipelineAdmissionOutcome) -> MindRefusal { + match outcome { + PipelineAdmissionOutcome::Refused(refusal) => refusal, + other => panic!("expected a refusal, got {other:?}"), + } +} + +pub(crate) fn committed(outcome: PipelineAdmissionOutcome) -> (String, Vec) { + match outcome { + PipelineAdmissionOutcome::Committed { receipt_id, writes, .. } => (receipt_id, writes), + other => panic!("expected a commit, got {other:?}"), + } +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 83e4bf2..c6857b0 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -5,8 +5,10 @@ //! type, a foreign epoch, a missing identity or a foreign identity before it //! attaches anything. `receipt::commit` owns "did a batch enter, whole, with a //! receipt": one compare-and-swap over the store, a receipt naming the exact -//! bytes read and written, and a typed conflict when the swap loses. The -//! admission path (Cut 8's third commit) owns "may this batch enter". +//! bytes read and written, and a typed conflict when the swap loses. +//! `Mind::admit_prepared` owns "may this batch enter": every cross-field and +//! cross-document rule, every derived write, and the replay check, in one +//! ordered path that `admit`, the daemon's sink and the hand-off all share. //! //! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the //! organ registers, prepares, decodes and validates through the leaf's four @@ -15,6 +17,7 @@ //! invariant's mechanism. No function here reads a clock or the environment: //! `now` is passed in. +pub mod admission; pub mod mind; pub mod receipt; pub mod refusal; @@ -23,6 +26,7 @@ pub mod store; #[cfg(test)] pub(crate) mod fixtures; +pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; pub use mind::{HuginnMindEpoch, Mind}; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index 3680585..31d69f9 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -1,6 +1,6 @@ //! The commit receipt: what a batch read and what it wrote, byte for byte. //! -//! This is the bounded duplicate of `epiphany-core`'s receipt (FU-3), smaller +//! This is the bounded duplicate of Epiphany's Mind receipt (FU-3), smaller //! by design: no authority enum, no companion documents, no `invariant_owner`, //! no `store_id`. Provenance is a field on the receipt, not a companion, and //! the digest excludes it, so an exact replay across sessions and faculties