Skip to main content

tea_session/
store_engine.rs

1//! Shared append-validation and materialization engine for session stores.
2//!
3//! Both the in-memory reference store and the durable `SQLite` store call into
4//! this engine so their observable behavior is identical: expected-sequence
5//! conflicts, grant-id deduplication, journal-revision guards, and reducer
6//! replay all happen through one code path.
7
8use tea_policy::{ActorId, GrantId, PolicyGrant};
9use tea_protocol::{RecordEnvelope, SessionSequence};
10
11use crate::artifact::ArtifactState;
12use crate::{
13    AppendOutcome, AppendTransaction, ApprovalArtifactEntry, GrantJournalEntry,
14    MaterializedSessionState, SessionReducer, SessionSnapshot, SessionStoreError,
15    SessionStoreErrorCode,
16};
17
18/// Immutable view of one stored session's durable facts.
19///
20/// A store loads or reconstructs this view before calling [`apply_transaction`].
21#[derive(Debug, Clone, Default)]
22pub struct StoredSession {
23    /// Canonical source records in authoritative sequence order.
24    pub records: Vec<RecordEnvelope>,
25    /// Rich approval artifacts linked to canonical approval transitions.
26    pub approval_artifacts: Vec<ApprovalArtifactEntry>,
27    /// Append-only grant journal facts.
28    pub grant_journal: Vec<GrantJournalEntry>,
29    /// Derived authorization state rebuilt from the journals.
30    pub artifacts: ArtifactState,
31    /// Current side-journal revision.
32    pub journal_revision: u64,
33    /// Incremental canonical reducer rebuilt once when durable facts are loaded.
34    pub reducer: SessionReducer,
35}
36
37impl StoredSession {
38    /// Rebuilds one validated stored session from durable facts.
39    ///
40    /// # Errors
41    ///
42    /// Returns a replay error when canonical records are corrupt.
43    pub fn from_durable_facts(
44        records: Vec<RecordEnvelope>,
45        approval_artifacts: Vec<ApprovalArtifactEntry>,
46        grant_journal: Vec<GrantJournalEntry>,
47    ) -> Result<Self, SessionStoreError> {
48        let reducer = SessionReducer::replay_reducer(records.iter().cloned())?;
49        let artifacts = ArtifactState::rebuild_from_journals(&approval_artifacts, &grant_journal);
50        let journal_revision = approval_artifacts
51            .len()
52            .checked_add(grant_journal.len())
53            .and_then(|revision| u64::try_from(revision).ok())
54            .ok_or_else(|| sequence_conflict("policy journal revision is out of range"))?;
55        Ok(Self {
56            records,
57            approval_artifacts,
58            grant_journal,
59            artifacts,
60            journal_revision,
61            reducer,
62        })
63    }
64
65    /// Builds a read snapshot from this stored view.
66    #[must_use]
67    pub fn snapshot(&self) -> SessionSnapshot {
68        SessionSnapshot::new(
69            self.records.clone(),
70            self.state(),
71            self.approval_artifacts.clone(),
72            self.grant_journal.clone(),
73            self.artifacts.active_grants(),
74            self.journal_revision,
75        )
76    }
77
78    /// Replays the durable records to rebuild materialized state.
79    ///
80    /// # Panics
81    ///
82    /// Panics when the stored records are corrupt; durable stores validate
83    /// every transaction before persistence.
84    #[must_use]
85    pub fn state(&self) -> MaterializedSessionState {
86        self.reducer
87            .state()
88            .cloned()
89            .expect("stored records include a validated creation record")
90    }
91}
92
93/// Validates a transaction and applies it to an existing stored view.
94///
95/// `grant_id_in_use` returns whether a grant id is already issued across every
96/// session (the in-memory store checks its map; the `SQLite` store queries its
97/// grant-journal table). Returns the new stored view and the append outcome.
98///
99/// # Errors
100///
101/// Returns a typed store error for empty transactions, cross-session records,
102/// stale expected sequences, stale journal revisions, duplicate grant ids, or
103/// reducer/artifact validation failures.
104#[allow(clippy::too_many_lines)]
105pub fn apply_transaction(
106    transaction: &AppendTransaction,
107    existing: Option<&StoredSession>,
108    grant_id_in_use: impl Fn(GrantId) -> bool,
109) -> Result<(StoredSession, AppendOutcome), SessionStoreError> {
110    let existed = existing.is_some();
111    let mut stored = existing.cloned().unwrap_or_default();
112    let outcome = apply_transaction_in_place(transaction, &mut stored, existed, grant_id_in_use)?;
113    Ok((stored, outcome))
114}
115
116/// Validates and applies one transaction to an owned stored-session cache entry.
117///
118/// The success path mutates only the transaction delta. If a record or artifact
119/// fails validation, the reducer is rebuilt from the unchanged durable vectors
120/// before the error is returned.
121///
122/// # Errors
123///
124/// Returns the same stable validation errors as [`apply_transaction`].
125#[allow(clippy::too_many_lines)]
126pub fn apply_transaction_in_place(
127    transaction: &AppendTransaction,
128    stored: &mut StoredSession,
129    existed: bool,
130    grant_id_in_use: impl Fn(GrantId) -> bool,
131) -> Result<AppendOutcome, SessionStoreError> {
132    validate_transaction_shape(transaction)?;
133    validate_expectation(existed.then_some(&*stored), transaction)?;
134    validate_global_grant_ids(
135        existed.then_some(&*stored),
136        transaction.grant_entries(),
137        &grant_id_in_use,
138    )?;
139
140    let previous_sequence = if existed {
141        Some(
142            stored
143                .reducer
144                .state()
145                .ok_or_else(missing_materialized_state)?
146                .tail_sequence(),
147        )
148    } else {
149        None
150    };
151    let current_journal_revision = stored.journal_revision;
152    let journal_entries = transaction
153        .approval_artifacts()
154        .len()
155        .checked_add(transaction.grant_entries().len())
156        .and_then(|count| u64::try_from(count).ok())
157        .ok_or_else(|| sequence_conflict("policy journal entry count is out of range"))?;
158    if journal_entries > 0
159        && transaction.expected_journal_revision() != Some(current_journal_revision)
160    {
161        return Err(sequence_conflict(
162            "expected policy journal revision is stale",
163        ));
164    }
165    let journal_revision = current_journal_revision
166        .checked_add(journal_entries)
167        .ok_or_else(|| sequence_conflict("policy journal revision cannot advance"))?;
168
169    for record in transaction.records() {
170        if let Err(error) = stored.reducer.apply(record) {
171            stored.reducer = rebuild_reducer(&stored.records)?;
172            return Err(error.into());
173        }
174    }
175
176    let original_record_len = stored.records.len();
177    stored.records.extend_from_slice(transaction.records());
178    let mut artifacts = stored.artifacts.clone();
179    if let Err(error) = artifacts.apply(
180        transaction.session_id(),
181        &stored.records,
182        transaction.records(),
183        transaction.approval_artifacts(),
184        transaction.grant_entries(),
185    ) {
186        stored.records.truncate(original_record_len);
187        stored.reducer = rebuild_reducer(&stored.records)?;
188        return Err(SessionStoreError::new(
189            error.store_code(),
190            error.to_string(),
191        ));
192    }
193
194    stored
195        .approval_artifacts
196        .extend_from_slice(transaction.approval_artifacts());
197    stored
198        .grant_journal
199        .extend_from_slice(transaction.grant_entries());
200    stored.artifacts = artifacts;
201    stored.journal_revision = journal_revision;
202    let state = stored
203        .reducer
204        .state()
205        .cloned()
206        .ok_or_else(missing_materialized_state)?;
207    let current_sequence = state.tail_sequence();
208    Ok(AppendOutcome::new(
209        previous_sequence,
210        current_sequence,
211        state,
212        journal_revision,
213    ))
214}
215
216fn rebuild_reducer(records: &[RecordEnvelope]) -> Result<SessionReducer, SessionStoreError> {
217    if records.is_empty() {
218        Ok(SessionReducer::new())
219    } else {
220        SessionReducer::replay_reducer(records.iter().cloned()).map_err(Into::into)
221    }
222}
223
224fn missing_materialized_state() -> SessionStoreError {
225    SessionStoreError::new(
226        SessionStoreErrorCode::CorruptionDetected,
227        "stored session is missing materialized state",
228    )
229}
230
231/// Returns the active grants for one actor across a collection of stored views.
232pub fn active_grants_for_actor<'a>(
233    sessions: impl Iterator<Item = &'a StoredSession>,
234    actor_id: &ActorId,
235) -> Vec<PolicyGrant> {
236    let mut grants = sessions
237        .flat_map(|stored| stored.artifacts.active_grants())
238        .filter(|grant| grant.actor_id() == actor_id)
239        .collect::<Vec<_>>();
240    grants.sort_by_key(PolicyGrant::id);
241    grants.dedup_by_key(|grant| grant.id());
242    grants
243}
244
245pub(crate) fn validate_transaction_shape(
246    transaction: &AppendTransaction,
247) -> Result<(), SessionStoreError> {
248    if transaction.records().is_empty()
249        && transaction.approval_artifacts().is_empty()
250        && transaction.grant_entries().is_empty()
251    {
252        return Err(SessionStoreError::new(
253            SessionStoreErrorCode::InvalidRecord,
254            "append transaction must contain a canonical or typed journal fact",
255        ));
256    }
257    if transaction
258        .records()
259        .iter()
260        .any(|record| record.session_id() != transaction.session_id())
261    {
262        return Err(SessionStoreError::new(
263            SessionStoreErrorCode::InvalidRecord,
264            "append transaction contains another session",
265        ));
266    }
267    Ok(())
268}
269
270pub(crate) fn validate_expectation(
271    existing: Option<&StoredSession>,
272    transaction: &AppendTransaction,
273) -> Result<(), SessionStoreError> {
274    let existing_records =
275        existing.map_or(&[] as &[RecordEnvelope], |stored| stored.records.as_slice());
276    let tail = existing_records.len().checked_sub(1);
277    match (existing_records.is_empty(), transaction.expected_sequence()) {
278        (true, None) => {
279            let first = transaction.records().first().ok_or_else(|| {
280                SessionStoreError::new(
281                    SessionStoreErrorCode::InvalidRecord,
282                    "session creation requires a canonical creation record",
283                )
284            })?;
285            if first.sequence() != SessionSequence::new(0) {
286                return Err(sequence_conflict("new session must begin at sequence zero"));
287            }
288        }
289        (true, Some(_)) => {
290            return Err(SessionStoreError::new(
291                SessionStoreErrorCode::SessionNotFound,
292                "cannot append to a missing session",
293            ));
294        }
295        (false, None) => {
296            return Err(SessionStoreError::new(
297                SessionStoreErrorCode::SessionAlreadyExists,
298                "session already exists",
299            ));
300        }
301        (false, Some(expected)) => {
302            let stored_tail = tail
303                .and_then(|index| existing_records.get(index))
304                .map_or(SessionSequence::new(0), RecordEnvelope::sequence);
305            if stored_tail != expected {
306                return Err(sequence_conflict("expected session sequence is stale"));
307            }
308            let next = expected
309                .checked_next()
310                .ok_or_else(|| sequence_conflict("session sequence cannot advance"))?;
311            if transaction
312                .records()
313                .first()
314                .is_some_and(|record| record.sequence() != next)
315            {
316                return Err(sequence_conflict(
317                    "first appended record does not follow expected sequence",
318                ));
319            }
320        }
321    }
322    Ok(())
323}
324
325pub(crate) fn validate_global_grant_ids(
326    existing: Option<&StoredSession>,
327    entries: &[GrantJournalEntry],
328    grant_id_in_use: &impl Fn(GrantId) -> bool,
329) -> Result<(), SessionStoreError> {
330    let mut seen_in_batch = std::collections::HashSet::new();
331    for entry in entries {
332        if matches!(entry, GrantJournalEntry::Issued { .. }) {
333            let id = entry.grant_id();
334            if !seen_in_batch.insert(id) {
335                return Err(SessionStoreError::new(
336                    SessionStoreErrorCode::InvalidRecord,
337                    "grant identity is already issued",
338                ));
339            }
340            if grant_id_in_use(id)
341                || existing.is_some_and(|stored| {
342                    stored
343                        .grant_journal
344                        .iter()
345                        .any(|existing| existing.grant_id() == id)
346                })
347            {
348                return Err(SessionStoreError::new(
349                    SessionStoreErrorCode::InvalidRecord,
350                    "grant identity is already issued",
351                ));
352            }
353        }
354    }
355    Ok(())
356}
357
358pub(crate) fn sequence_conflict(message: &str) -> SessionStoreError {
359    SessionStoreError::new(SessionStoreErrorCode::SequenceConflict, message)
360}