Skip to main content

graphforge_knowledge/research/
decisions.rs

1//! Append-only decisions are ordered by publication, not caller-provided dates.
2use super::{
3    MAX_RESEARCH_ROWS, ResearchAuthority, ResearchDecisionKind, ResearchDecisionRecord,
4    ResearchSubjectKind,
5};
6use crate::{KnowledgeError, invalid, require_uuid, require_v7};
7use std::collections::{HashMap, HashSet};
8use uuid::Uuid;
9
10/// Immutable canonical/integration history across all contexts of one container.
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct ResearchDecisionLedger {
13    events: Vec<ResearchDecisionRecord>,
14}
15impl ResearchDecisionLedger {
16    /// Encode complete current history as native Arrow.
17    pub fn batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
18        super::encoding::decision_batch(&self.events)
19    }
20    /// Decode complete history; filtered views are not valid publication ledgers.
21    pub fn from_batches(
22        batches: &[arrow::record_batch::RecordBatch],
23    ) -> Result<Self, KnowledgeError> {
24        Self::new(super::encoding::decision_rows(batches)?)
25    }
26    /// Validate complete history; a missing ordinal is corruption, not expiry.
27    pub fn new(mut events: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
28        if events.len() > MAX_RESEARCH_ROWS {
29            return Err(KnowledgeError::Limit {
30                participant: "research_decisions",
31                observed: events.len(),
32                limit: MAX_RESEARCH_ROWS,
33            });
34        }
35        events.sort_by_key(|event| event.sequence);
36        let mut ids = HashSet::new();
37        let mut requests = HashMap::new();
38        for (index, event) in events.iter().enumerate() {
39            validate(event)?;
40            if event.sequence != index as u64 + 1 {
41                return Err(invalid(
42                    "research_decision.sequence",
43                    "history must have contiguous publication order",
44                ));
45            }
46            if !ids.insert(event.decision_uuid) {
47                return Err(KnowledgeError::Duplicate("decision_uuid"));
48            }
49            if requests
50                .insert(event.operation_uuid, event.request_sha256)
51                .is_some_and(|old| old != event.request_sha256)
52            {
53                return Err(KnowledgeError::Conflict("research_decision.operation_uuid"));
54            }
55        }
56        Ok(Self { events })
57    }
58
59    /// Append exact records or return an unchanged ledger on exact event replay.
60    pub fn append(&self, staged: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
61        let mut events = self.events.clone();
62        let by_id: HashMap<_, _> = self
63            .events
64            .iter()
65            .map(|event| (event.decision_uuid, event))
66            .collect();
67        for event in staged {
68            if let Some(existing) = by_id.get(&event.decision_uuid) {
69                if *existing != &event {
70                    return Err(KnowledgeError::Conflict("decision_uuid"));
71                }
72            } else {
73                events.push(event);
74            }
75        }
76        Self::new(events)
77    }
78
79    /// Inspect all history, including introduction and revoked decisions.
80    #[must_use]
81    pub fn events(&self) -> &[ResearchDecisionRecord] {
82        &self.events
83    }
84
85    /// Latest explicit canonical decision only in the exact requested authority.
86    /// An integration event, source context, or different community cannot grant it.
87    #[must_use]
88    pub fn canonical_decision(
89        &self,
90        authority: &ResearchAuthority,
91        subject_kind: ResearchSubjectKind,
92        subject_uuid: Uuid,
93    ) -> Option<&ResearchDecisionRecord> {
94        self.events.iter().rev().find(|event| {
95            &event.authority == authority
96                && event.subject_kind == subject_kind
97                && event.subject_uuid == subject_uuid
98                && event.kind != ResearchDecisionKind::Integrate
99        })
100    }
101}
102
103fn validate(event: &ResearchDecisionRecord) -> Result<(), KnowledgeError> {
104    require_v7(event.decision_uuid, "decision_uuid")?;
105    for (id, field) in [
106        (event.operation_uuid, "operation_uuid"),
107        (event.authority.project_uuid, "project_uuid"),
108        (event.authority.context_uuid, "context_uuid"),
109        (event.subject_uuid, "subject_uuid"),
110        (event.creator_uuid, "creator_uuid"),
111    ] {
112        require_uuid(id, field)?;
113    }
114    if let Some(id) = event.authority.community_uuid {
115        require_uuid(id, "community_uuid")?;
116    }
117    if let Some(id) = event.source_version_uuid {
118        require_uuid(id, "source_version_uuid")?;
119    }
120    if event.subject_kind == ResearchSubjectKind::Assertion {
121        require_v7(event.subject_uuid, "assertion_uuid")?;
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    fn event(
130        sequence: u64,
131        authority: ResearchAuthority,
132        subject_uuid: Uuid,
133        kind: ResearchDecisionKind,
134    ) -> ResearchDecisionRecord {
135        ResearchDecisionRecord {
136            sequence,
137            decision_uuid: Uuid::now_v7(),
138            operation_uuid: Uuid::now_v7(),
139            request_sha256: [1; 32],
140            authority,
141            subject_kind: ResearchSubjectKind::Assertion,
142            subject_uuid,
143            kind,
144            creator_uuid: Uuid::now_v7(),
145            source_version_uuid: None,
146            recorded_at: 100 - i64::try_from(sequence).unwrap(),
147        }
148    }
149    #[test]
150    fn integration_and_source_acceptance_do_not_promote_destination() {
151        let parent = ResearchAuthority {
152            project_uuid: Uuid::now_v7(),
153            community_uuid: None,
154            context_uuid: Uuid::now_v7(),
155        };
156        let child = ResearchAuthority {
157            context_uuid: Uuid::now_v7(),
158            ..parent.clone()
159        };
160        let subject = Uuid::now_v7();
161        let accepted = event(1, parent.clone(), subject, ResearchDecisionKind::Promote);
162        let integrated = event(2, child.clone(), subject, ResearchDecisionKind::Integrate);
163        let ledger = ResearchDecisionLedger::new(vec![accepted.clone(), integrated]).unwrap();
164        assert_eq!(
165            ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
166            Some(&accepted)
167        );
168        assert!(
169            ledger
170                .canonical_decision(&child, ResearchSubjectKind::Assertion, subject)
171                .is_none()
172        );
173        let promoted = event(3, child.clone(), subject, ResearchDecisionKind::Promote);
174        let revoked = event(4, child.clone(), subject, ResearchDecisionKind::Revoke);
175        let ledger = ledger.append(vec![promoted, revoked.clone()]).unwrap();
176        assert_eq!(
177            ledger.canonical_decision(&child, ResearchSubjectKind::Assertion, subject),
178            Some(&revoked)
179        );
180        assert_eq!(
181            ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
182            Some(&accepted)
183        );
184        let other_community = ResearchAuthority {
185            community_uuid: Some(Uuid::now_v7()),
186            ..child
187        };
188        assert!(
189            ledger
190                .canonical_decision(&other_community, ResearchSubjectKind::Assertion, subject)
191                .is_none()
192        );
193        assert_eq!(ledger.events().len(), 4);
194    }
195    #[test]
196    fn history_refuses_identity_rewrite_gaps_and_changed_operation_content() {
197        let authority = ResearchAuthority {
198            project_uuid: Uuid::now_v7(),
199            community_uuid: None,
200            context_uuid: Uuid::now_v7(),
201        };
202        let first = event(1, authority, Uuid::now_v7(), ResearchDecisionKind::Promote);
203        let ledger = ResearchDecisionLedger::new(vec![first.clone()]).unwrap();
204        assert_eq!(ledger.append(vec![first.clone()]).unwrap(), ledger);
205        let mut changed = first.clone();
206        changed.kind = ResearchDecisionKind::Revoke;
207        assert!(matches!(
208            ledger.append(vec![changed]),
209            Err(KnowledgeError::Conflict("decision_uuid"))
210        ));
211        let mut next = first.clone();
212        next.sequence = 2;
213        next.decision_uuid = Uuid::now_v7();
214        next.request_sha256 = [99; 32];
215        assert!(matches!(
216            ledger.append(vec![next]),
217            Err(KnowledgeError::Conflict("research_decision.operation_uuid"))
218        ));
219        let mut gap = first;
220        gap.sequence = 3;
221        gap.decision_uuid = Uuid::now_v7();
222        assert!(ledger.append(vec![gap]).is_err());
223        assert_eq!(ledger.events().len(), 1);
224    }
225}