diff --git a/crates/huginn-mind/src/persona.rs b/crates/huginn-mind/src/persona.rs index a7677a9..63960fe 100644 --- a/crates/huginn-mind/src/persona.rs +++ b/crates/huginn-mind/src/persona.rs @@ -164,6 +164,7 @@ mod tests { use crate::fixtures::{INSTANCE, OTHER_INSTANCE, epoch, instance, opened, prepare, provenance, seed, slug}; use crate::receipt::Faculty; use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; + use cultnet_rs::Selection; use eureka_pipeline::PipelineKind; use serde_json::json; @@ -306,6 +307,70 @@ mod tests { } } + /// The persona is the one identity replaced by design: a second put is a + /// second receipt naming it, and every pipeline read still answers. + #[test] + fn reads_answer_after_a_second_persona_put() { + let store = MemoryStore::new(); + let mut mind = seeded_over(&store); + committed(put(&mut mind, persona(INSTANCE, T1), None)); + let second = committed(put(&mut mind, persona(INSTANCE, T2), Some(T1))); + committed(put(&mut mind, persona(INSTANCE, T3), Some(T2))); + assert_eq!(mind.persona().unwrap().unwrap().updated_at, T3); + assert_reads_answer(&mind); + assert_ne!(mind.persona().unwrap().unwrap().receipt_id, second); + assert_reads_answer(&opened(store.clone(), INSTANCE)); + } + + /// The same, over a real store file closed and reopened. + #[test] + fn a_reopened_disk_store_reads_after_two_persona_puts() { + let root = tempfile::tempdir().unwrap(); + { + let mut mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + seed(&mut mind); + committed(put(&mut mind, persona(INSTANCE, T1), None)); + committed(put(&mut mind, persona(INSTANCE, T2), Some(T1))); + } + let mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + assert_eq!(mind.persona().unwrap().unwrap().updated_at, T2); + assert_reads_answer(&mind); + } + + /// The exemption is the persona's alone: a pipeline document written by + /// two receipts still refuses, persona re-puts or not. + #[test] + fn a_doubled_pipeline_write_still_refuses_beside_persona_reputs() { + let store = MemoryStore::new(); + let mut mind = seeded_over(&store); + committed(put(&mut mind, persona(INSTANCE, T1), None)); + committed(put(&mut mind, persona(INSTANCE, T2), Some(T1))); + let doubled = MemoryStore::new(); + for row in store.rows() { + doubled.plant(row); + } + let head = crate::receipt::head(&mind).unwrap(); + let again = crate::fixtures::prepare(&crate::fixtures::instance(INSTANCE)); + let forged = crate::receipt::candidate(&slug(INSTANCE), provenance(Faculty::Soul), &[], &[again], head + 1, crate::fixtures::now()) + .unwrap(); + doubled.plant(crate::mind::schema_cache().unwrap().prepare_entry_named(&forged.receipt_id, &forged).unwrap().0); + let refusal = opened(doubled, INSTANCE).query(&Selection::default()).err(); + assert!( + matches!(&refusal, Some(MindRefusal::Unavailable { detail }) if detail.ends_with("is written by two receipts")), + "{refusal:?}" + ); + } + + fn assert_reads_answer(mind: &Mind) { + let page = mind.query(&Selection::default()).unwrap(); + assert!(page.matched > 0); + let first = match &page.items { + crate::query::PipelinePageItems::Headers(headers) => headers[0].id.clone(), + crate::query::PipelinePageItems::Documents(views) => views[0].id.clone(), + }; + assert!(mind.view(&first).unwrap().is_some()); + } + #[test] fn a_persona_document_is_validated_at_the_door() { let store = MemoryStore::new(); diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 54a028e..2dd1107 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -22,6 +22,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use cultcache_rs::DatabaseEntry; use cultnet_rs::{ Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Row, Selection, select, validate, }; @@ -35,6 +36,7 @@ use serde::{Deserialize, Serialize}; use crate::docs::{CitationRole, Docs, Held, citations, kind_of_type}; use crate::mind::{Mind, schema_cache}; +use crate::persona::HuginnPersonaEntry; use crate::receipt::{self, PipelineProvenance}; use crate::refusal::MindRefusal; use crate::rows::{SelectionRow, Vocabulary, refuse_values}; @@ -310,8 +312,12 @@ pub struct Ranked { /// The admission facts of every stored document, built once per read call from /// the receipts. `writes` only: a document a later batch cited as a strong read -/// keeps the receipt that wrote it. A10 admits one write per identity, so two -/// receipts naming one document is an integrity fault, not a choice to make. +/// keeps the receipt that wrote it. A10 admits one write per pipeline identity, +/// so two receipts naming one pipeline document is an integrity fault, not a +/// choice to make. The persona document is the one identity replaced by design +/// (`put_persona` is a compare-and-swap on `updatedAt`) and is not a pipeline +/// document: it has no admission facts here, and `Mind::persona` reads its own +/// receipt. pub(crate) struct AdmissionIndex(BTreeMap<(String, String), AdmissionFacts>); impl AdmissionIndex { @@ -323,7 +329,7 @@ impl AdmissionIndex { receipt::head(mind)?; let mut facts = BTreeMap::new(); for receipt in mind.receipts()? { - for write in receipt.writes.iter() { + for write in receipt.writes.iter().filter(|write| write.document_type != HuginnPersonaEntry::TYPE) { let identity = (write.document_type.clone(), write.document_key.clone()); let landed = AdmissionFacts { receipt_id: receipt.receipt_id.clone(),