huginn-mind: a persona re-put no longer poisons reads; the admission index covers pipeline documents only

The persona document is replaced by design (compare-and-swap on updatedAt), so
its second put is a second receipt naming one identity. AdmissionIndex::build
read that as a doubled A10 write and refused every view and query. The persona
type has no admission facts there now; Mind::persona reads its own receipt. A
pipeline document written by two receipts still refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 00:46:22 +01:00
commit 577cae23a7
2 changed files with 74 additions and 3 deletions

View file

@ -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<impl MindStore>) {
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();

View file

@ -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(),