use super::{
MAX_RESEARCH_ROWS, ResearchAuthority, ResearchDecisionKind, ResearchDecisionRecord,
ResearchSubjectKind,
};
use crate::{KnowledgeError, invalid, require_uuid, require_v7};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResearchDecisionLedger {
events: Vec<ResearchDecisionRecord>,
}
impl ResearchDecisionLedger {
pub fn batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
super::encoding::decision_batch(&self.events)
}
pub fn from_batches(
batches: &[arrow::record_batch::RecordBatch],
) -> Result<Self, KnowledgeError> {
Self::new(super::encoding::decision_rows(batches)?)
}
pub fn new(mut events: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
if events.len() > MAX_RESEARCH_ROWS {
return Err(KnowledgeError::Limit {
participant: "research_decisions",
observed: events.len(),
limit: MAX_RESEARCH_ROWS,
});
}
events.sort_by_key(|event| event.sequence);
let mut ids = HashSet::new();
let mut requests = HashMap::new();
for (index, event) in events.iter().enumerate() {
validate(event)?;
if event.sequence != index as u64 + 1 {
return Err(invalid(
"research_decision.sequence",
"history must have contiguous publication order",
));
}
if !ids.insert(event.decision_uuid) {
return Err(KnowledgeError::Duplicate("decision_uuid"));
}
if requests
.insert(event.operation_uuid, event.request_sha256)
.is_some_and(|old| old != event.request_sha256)
{
return Err(KnowledgeError::Conflict("research_decision.operation_uuid"));
}
}
Ok(Self { events })
}
pub fn append(&self, staged: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
let mut events = self.events.clone();
let by_id: HashMap<_, _> = self
.events
.iter()
.map(|event| (event.decision_uuid, event))
.collect();
for event in staged {
if let Some(existing) = by_id.get(&event.decision_uuid) {
if *existing != &event {
return Err(KnowledgeError::Conflict("decision_uuid"));
}
} else {
events.push(event);
}
}
Self::new(events)
}
#[must_use]
pub fn events(&self) -> &[ResearchDecisionRecord] {
&self.events
}
#[must_use]
pub fn canonical_decision(
&self,
authority: &ResearchAuthority,
subject_kind: ResearchSubjectKind,
subject_uuid: Uuid,
) -> Option<&ResearchDecisionRecord> {
self.events.iter().rev().find(|event| {
&event.authority == authority
&& event.subject_kind == subject_kind
&& event.subject_uuid == subject_uuid
&& event.kind != ResearchDecisionKind::Integrate
})
}
}
fn validate(event: &ResearchDecisionRecord) -> Result<(), KnowledgeError> {
require_v7(event.decision_uuid, "decision_uuid")?;
for (id, field) in [
(event.operation_uuid, "operation_uuid"),
(event.authority.project_uuid, "project_uuid"),
(event.authority.context_uuid, "context_uuid"),
(event.subject_uuid, "subject_uuid"),
(event.creator_uuid, "creator_uuid"),
] {
require_uuid(id, field)?;
}
if let Some(id) = event.authority.community_uuid {
require_uuid(id, "community_uuid")?;
}
if let Some(id) = event.source_version_uuid {
require_uuid(id, "source_version_uuid")?;
}
if event.subject_kind == ResearchSubjectKind::Assertion {
require_v7(event.subject_uuid, "assertion_uuid")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn event(
sequence: u64,
authority: ResearchAuthority,
subject_uuid: Uuid,
kind: ResearchDecisionKind,
) -> ResearchDecisionRecord {
ResearchDecisionRecord {
sequence,
decision_uuid: Uuid::now_v7(),
operation_uuid: Uuid::now_v7(),
request_sha256: [1; 32],
authority,
subject_kind: ResearchSubjectKind::Assertion,
subject_uuid,
kind,
creator_uuid: Uuid::now_v7(),
source_version_uuid: None,
recorded_at: 100 - i64::try_from(sequence).unwrap(),
}
}
#[test]
fn integration_and_source_acceptance_do_not_promote_destination() {
let parent = ResearchAuthority {
project_uuid: Uuid::now_v7(),
community_uuid: None,
context_uuid: Uuid::now_v7(),
};
let child = ResearchAuthority {
context_uuid: Uuid::now_v7(),
..parent.clone()
};
let subject = Uuid::now_v7();
let accepted = event(1, parent.clone(), subject, ResearchDecisionKind::Promote);
let integrated = event(2, child.clone(), subject, ResearchDecisionKind::Integrate);
let ledger = ResearchDecisionLedger::new(vec![accepted.clone(), integrated]).unwrap();
assert_eq!(
ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
Some(&accepted)
);
assert!(
ledger
.canonical_decision(&child, ResearchSubjectKind::Assertion, subject)
.is_none()
);
let promoted = event(3, child.clone(), subject, ResearchDecisionKind::Promote);
let revoked = event(4, child.clone(), subject, ResearchDecisionKind::Revoke);
let ledger = ledger.append(vec![promoted, revoked.clone()]).unwrap();
assert_eq!(
ledger.canonical_decision(&child, ResearchSubjectKind::Assertion, subject),
Some(&revoked)
);
assert_eq!(
ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
Some(&accepted)
);
let other_community = ResearchAuthority {
community_uuid: Some(Uuid::now_v7()),
..child
};
assert!(
ledger
.canonical_decision(&other_community, ResearchSubjectKind::Assertion, subject)
.is_none()
);
assert_eq!(ledger.events().len(), 4);
}
#[test]
fn history_refuses_identity_rewrite_gaps_and_changed_operation_content() {
let authority = ResearchAuthority {
project_uuid: Uuid::now_v7(),
community_uuid: None,
context_uuid: Uuid::now_v7(),
};
let first = event(1, authority, Uuid::now_v7(), ResearchDecisionKind::Promote);
let ledger = ResearchDecisionLedger::new(vec![first.clone()]).unwrap();
assert_eq!(ledger.append(vec![first.clone()]).unwrap(), ledger);
let mut changed = first.clone();
changed.kind = ResearchDecisionKind::Revoke;
assert!(matches!(
ledger.append(vec![changed]),
Err(KnowledgeError::Conflict("decision_uuid"))
));
let mut next = first.clone();
next.sequence = 2;
next.decision_uuid = Uuid::now_v7();
next.request_sha256 = [99; 32];
assert!(matches!(
ledger.append(vec![next]),
Err(KnowledgeError::Conflict("research_decision.operation_uuid"))
));
let mut gap = first;
gap.sequence = 3;
gap.decision_uuid = Uuid::now_v7();
assert!(ledger.append(vec![gap]).is_err());
assert_eq!(ledger.events().len(), 1);
}
}