diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index b942e05..5240cb6 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -47,10 +47,8 @@ use crate::store::MindStore; 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)] +/// Rides the wire whole (`wire::HuginnMindRequest::Admit`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct PipelineAdmissionBatch { pub instance: Slug, pub provenance: PipelineProvenance, @@ -108,9 +106,7 @@ impl Mind { now: DateTime, ) -> Result { // A1 - if instance != self.instance() { - return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: self.instance().0.clone() }); - } + self.require_instance(instance)?; // A2 if envelopes.is_empty() || envelopes.len() > BATCH_MAX { return Err(MindRefusal::BatchSize { actual: envelopes.len() as u32 }); diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 45a0606..20345ee 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -39,4 +39,9 @@ pub use query::{ }; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; -pub use store::MindStore; +pub use store::{MindStore, OwnedRedbMessagePackBackingStore}; + +/// The leaf, whole, through the one crate that pins its rev: the daemon and +/// the client name `Slug`, `PipelineRef` and the document types from here, so +/// one `[dependencies]` entry decides which revision the workspace speaks. +pub use epiphany_pipeline; diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 12f7961..bf40e16 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -121,6 +121,20 @@ impl Mind { &self.instance } + /// Ruling 14 across every transport: the declared instance is this mind's, + /// else `ForeignInstance { declared, mind }`. A1 for admission; the daemon + /// asks it for every read that names an instance, and compares nothing + /// itself. + pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> { + if declared != self.instance() { + return Err(MindRefusal::ForeignInstance { + declared: declared.0.clone(), + mind: self.instance().0.clone(), + }); + } + Ok(()) + } + /// The image: every envelope the store held at the last pull, in /// identity order. Cut 10's snapshot source reads this. pub fn envelopes(&self) -> &[CultCacheEnvelope] { @@ -388,6 +402,27 @@ mod tests { assert_eq!(mind.envelopes().len(), 2); } + /// One check, one owner: the value admission refuses a foreign instance + /// with is the value `require_instance` returns, so the daemon asking it + /// for a read and `admit_steps` asking it for a write cannot disagree. + #[test] + fn require_instance_is_the_one_check_admission_and_the_daemon_share() { + use crate::fixtures::{OTHER_INSTANCE, now, provenance, seeded}; + use crate::receipt::Faculty; + + let mut mind = seeded(); + let foreign = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; + assert_eq!(mind.require_instance(&slug(OTHER_INSTANCE)).err(), Some(foreign.clone())); + assert_eq!(mind.require_instance(&slug(INSTANCE)), Ok(())); + + let batch = crate::admission::PipelineAdmissionBatch { + instance: slug(OTHER_INSTANCE), + provenance: provenance(Faculty::Hands), + documents: vec![instance(OTHER_INSTANCE)], + }; + assert_eq!(mind.admit(batch, now()), crate::admission::PipelineAdmissionOutcome::Refused(foreign)); + } + #[test] fn a_mind_has_one_owner_at_a_time() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 3f6846a..2087414 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -17,11 +17,7 @@ use serde::{Deserialize, Serialize}; /// still in force. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum MindRefusal { - Document( - #[serde(with = "DocumentRefusal")] - #[schemars(with = "DocumentRefusal")] - PipelineRefusal, - ), + Document(PipelineRefusal), ForeignInstance { declared: String, mind: String }, MissingIdentity, ForeignEpoch { found: String, expected: String }, @@ -61,23 +57,6 @@ pub enum MindRefusal { Unavailable { detail: String }, } -/// The leaf's `PipelineRefusal` at the pinned rev derives neither `Serialize` -/// nor `JsonSchema`. This is serde's remote definition of it: the same four -/// variants, field for field, compiled against the leaf's type, so a leaf -/// change that renames or adds a field stops this crate building rather than -/// drifting. It is a projection for the wire, not a second vocabulary; the -/// refusal itself is still the leaf's value. When the leaf derives the two -/// traits this goes. -#[derive(Serialize, Deserialize, JsonSchema)] -#[serde(remote = "PipelineRefusal")] -#[allow(dead_code)] -enum DocumentRefusal { - FieldBound { field: String, limit: u32, actual: u32 }, - InvalidFormat { field: String, value: String }, - InvalidIdentity { kind: PipelineKind, key: String, expected: String }, - ForeignStore { r#type: String }, -} - impl std::fmt::Display for MindRefusal { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "mind refusal: {self:?}") diff --git a/crates/huginn-mind/src/store.rs b/crates/huginn-mind/src/store.rs index d532fbd..58a3d66 100644 --- a/crates/huginn-mind/src/store.rs +++ b/crates/huginn-mind/src/store.rs @@ -5,7 +5,12 @@ //! atomicity test. Nothing else is abstracted. use anyhow::Result; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; + +/// The one store a daemon opens. Re-exported so the crate that serves a mind +/// names the store type without declaring `cultcache-rs` a second time: one +/// dependency entry, one rev, one place to move it. +pub use cultcache_rs::OwnedRedbMessagePackBackingStore; /// A store a mind can be opened over. `Clone` shares ownership of the same /// store (the owned redb store's clones share its lock and handle), which is diff --git a/tools/eureka-cut8-mutations.psd1 b/tools/eureka-cut8-mutations.psd1 index 4c8f053..17f1217 100644 --- a/tools/eureka-cut8-mutations.psd1 +++ b/tools/eureka-cut8-mutations.psd1 @@ -36,14 +36,18 @@ # and H67 by file alone, and H47 and H60 re-anchored on the scope selectors # `assignments_of` and `resolutions_of` the move extracted. Their rules are # unchanged. +# +# Cut 10 moved A1's comparison into `Mind::require_instance`, which admission +# and the daemon's reads now share, so H1 anchors on `mind.rs`. One mutant, +# two suites, one owner: the same edit is `D1` in `eureka-cut10-mutations.psd1`. @{ Mutations = @( @{ Id = 'H1' Rule = 'Ruling 14: admission refuses another instance''s identity (A1).' Test = 'admission::tests::admission_refuses_a_foreign_instance_whatever_the_transport' - File = 'crates/huginn-mind/src/admission.rs' - Old = ' if instance != self.instance() {' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' New = ' if false {' } @{