From 0bd71337fdeec5e9d70ed8c985195044621d2aa1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 17:13:44 +0200 Subject: [PATCH] Admit a batch through one ordered path with every rule in it Cut 8, Huginn half, commit 3 of 4: `admission.rs`. `Mind::admit` prepares typed documents through the leaf and calls `admit_prepared`, the single commit path for this cut, 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: the batch names this mind (A1), one to sixty-four envelopes (A2), the leaf's bounds, formats and key recomputation (A3), unique identities (A4), every instance-carrying document names this mind (A5), an empty mind's first batch carries exactly one `instance` document and a batch carrying one derives the epoch record (A6), every reference resolves in image or batch by `(kind, id)` (A7), the derived writes pass A3-A7 themselves, the per-kind rules and the resolution matrix (A8), an exact replay answers with the stored receipt only after validation (A9), no write collides with the image (A10), the commit (A11). Derived writes: 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. "In force" is computed at rule time from the resolutions in image and batch and never stored; the two derivations are the only resolutions a rule ignores when judging its own document. Two places the leaf is thinner than the spec assumed. `FindingWithoutRange` is unreachable: a finding envelope without `range` fails the leaf's decode as `Document(InvalidFormat { field: "payload" })` before any organ rule, and the test asserts that. A campaign with no repos has no refusal of its own in the spec's list; it is refused as `Document(FieldBound)` on `campaign.repos` with a minimum of one. `PipelineAdmissionBatch` cannot derive `JsonSchema` because the leaf's `PipelineDocument` does not; the outcome and refusal types do. The epoch record is derived whenever the batch carries the `instance` document, which only the first batch may, so a replay of the first batch digests to the same receipt and answers `AlreadyAdmitted` instead of colliding. Tests: 26 (the spec's 25 plus the replay-planting test, the receipt's deliberate second construction site). `cargo check -p huginn-mind --lib --tests` and `cargo test -p huginn-mind --lib` pass with zero warnings; `cargo check --workspace` passes. README says what the crate does now. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- crates/huginn-mind/src/admission.rs | 1340 +++++++++++++++++++++++++++ crates/huginn-mind/src/fixtures.rs | 310 ++++++- crates/huginn-mind/src/lib.rs | 8 +- crates/huginn-mind/src/receipt.rs | 2 +- 5 files changed, 1662 insertions(+), 8 deletions(-) create mode 100644 crates/huginn-mind/src/admission.rs 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