1use 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#[derive(Debug, Clone, Default)]
22pub struct StoredSession {
23 pub records: Vec<RecordEnvelope>,
25 pub approval_artifacts: Vec<ApprovalArtifactEntry>,
27 pub grant_journal: Vec<GrantJournalEntry>,
29 pub artifacts: ArtifactState,
31 pub journal_revision: u64,
33 pub reducer: SessionReducer,
35}
36
37impl StoredSession {
38 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 #[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 #[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#[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#[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
231pub 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}