Skip to main content

tea_session/
artifact.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use tea_policy::{ApprovalRequest, ApprovalResolution, GrantId, PolicyGrant};
5use tea_protocol::{ApprovalId, ProfileId, RecordEnvelope, RecordId, SessionId, SessionRecord};
6
7use crate::{SessionReducer, SessionStoreErrorCode};
8
9/// Rich policy approval value linked to a canonical durable approval transition.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12#[allow(clippy::large_enum_variant)] // Boxing would break the public artifact value API.
13pub enum ApprovalArtifactEntry {
14    /// Self-contained redacted request snapshot.
15    Requested {
16        /// Canonical record containing `approval_requested`.
17        record_id: RecordId,
18        /// Validated policy request.
19        request: ApprovalRequest,
20    },
21    /// Self-contained terminal resolution snapshot.
22    Resolved {
23        /// Canonical record containing `approval_resolved`.
24        record_id: RecordId,
25        /// Validated policy resolution.
26        resolution: ApprovalResolution,
27    },
28}
29
30impl ApprovalArtifactEntry {
31    /// Returns the linked canonical record.
32    #[must_use]
33    pub const fn record_id(&self) -> RecordId {
34        match self {
35            Self::Requested { record_id, .. } | Self::Resolved { record_id, .. } => *record_id,
36        }
37    }
38}
39
40/// Append-only authorization-grant journal fact.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum GrantJournalEntry {
44    /// A matching approval resolution issued the grant.
45    Issued {
46        /// Canonical approval-resolution record authorizing issuance.
47        approval_record_id: RecordId,
48        /// Immutable issued grant.
49        grant: PolicyGrant,
50    },
51    /// A previously issued grant became immutably revoked.
52    Revoked {
53        /// Immutable revoked form retaining the original grant identity.
54        grant: PolicyGrant,
55    },
56}
57
58impl GrantJournalEntry {
59    /// Returns the stable grant identity.
60    #[must_use]
61    pub const fn grant_id(&self) -> GrantId {
62        match self {
63            Self::Issued { grant, .. } | Self::Revoked { grant } => grant.id(),
64        }
65    }
66}
67
68#[derive(Debug, Clone)]
69struct ApprovalContext {
70    profile_id: ProfileId,
71    tool_name: String,
72}
73
74/// Derived authorization state rebuilt from approval and grant journals.
75#[derive(Debug, Clone, Default)]
76pub struct ArtifactState {
77    requests: BTreeMap<ApprovalId, ApprovalRequest>,
78    resolutions: BTreeSet<ApprovalId>,
79    grants: BTreeMap<GrantId, PolicyGrant>,
80}
81
82impl ArtifactState {
83    /// Reconstructs grant state from a persisted grant journal.
84    ///
85    /// Use [`rebuild_from_journals`](Self::rebuild_from_journals) when approval
86    /// artifacts are available and future approval resolutions may be appended.
87    #[must_use]
88    pub fn rebuild_from_journal(grant_journal: &[GrantJournalEntry]) -> Self {
89        Self::rebuild_from_journals(&[], grant_journal)
90    }
91
92    /// Reconstructs authorization state from persisted side journals.
93    ///
94    /// Unlike [`apply`](Self::apply), this trusts persisted facts without
95    /// re-validating them against canonical records; durable stores use it on
96    /// load where each journal fact was validated before persistence.
97    #[must_use]
98    pub fn rebuild_from_journals(
99        approvals: &[ApprovalArtifactEntry],
100        grant_journal: &[GrantJournalEntry],
101    ) -> Self {
102        let mut state = Self::default();
103        for entry in approvals {
104            match entry {
105                ApprovalArtifactEntry::Requested { request, .. } => {
106                    state
107                        .requests
108                        .insert(*request.approval_id(), request.clone());
109                }
110                ApprovalArtifactEntry::Resolved { resolution, .. } => {
111                    state
112                        .resolutions
113                        .insert(*resolution.request().approval_id());
114                }
115            }
116        }
117        for entry in grant_journal {
118            match entry {
119                GrantJournalEntry::Issued { grant, .. } | GrantJournalEntry::Revoked { grant } => {
120                    state.grants.insert(grant.id(), grant.clone());
121                }
122            }
123        }
124        state
125    }
126
127    /// Returns non-revoked grant candidates in stable grant-id order.
128    #[must_use]
129    pub fn active_grants(&self) -> Vec<PolicyGrant> {
130        self.grants
131            .values()
132            .filter(|grant| grant.revoked_at().is_none())
133            .cloned()
134            .collect()
135    }
136
137    /// Applies approval and grant journal facts linked to a transaction.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error when an artifact references a missing canonical record.
142    pub fn apply(
143        &mut self,
144        session_id: SessionId,
145        durable_records: &[RecordEnvelope],
146        transaction_records: &[RecordEnvelope],
147        approvals: &[ApprovalArtifactEntry],
148        grants: &[GrantJournalEntry],
149    ) -> Result<(), ArtifactValidationError> {
150        let record_index = transaction_records
151            .iter()
152            .map(|record| (record.record_id(), record))
153            .collect::<BTreeMap<_, _>>();
154        let contexts = approval_contexts(durable_records)?;
155        let resolutions = self.apply_approvals(session_id, &record_index, &contexts, approvals)?;
156        self.apply_grants(grants, &resolutions)
157    }
158
159    fn apply_approvals<'a>(
160        &mut self,
161        session_id: SessionId,
162        record_index: &BTreeMap<RecordId, &RecordEnvelope>,
163        contexts: &BTreeMap<ApprovalId, ApprovalContext>,
164        approvals: &'a [ApprovalArtifactEntry],
165    ) -> Result<BTreeMap<RecordId, &'a ApprovalResolution>, ArtifactValidationError> {
166        let mut resolutions = BTreeMap::new();
167        for entry in approvals {
168            let record = record_index
169                .get(&entry.record_id())
170                .ok_or(ArtifactValidationError::MissingCanonicalRecord)?;
171            match (entry, record.record()) {
172                (
173                    ApprovalArtifactEntry::Requested { request, .. },
174                    SessionRecord::ApprovalRequested {
175                        approval_id,
176                        tool_call_id,
177                        expires_at,
178                    },
179                ) => self.apply_request(
180                    session_id,
181                    record,
182                    request,
183                    *approval_id,
184                    *tool_call_id,
185                    *expires_at,
186                    contexts,
187                )?,
188                (
189                    ApprovalArtifactEntry::Resolved { resolution, .. },
190                    SessionRecord::ApprovalResolved {
191                        approval_id,
192                        decision,
193                    },
194                ) => {
195                    self.apply_resolution(record, resolution, *approval_id, *decision)?;
196                    resolutions.insert(record.record_id(), resolution);
197                }
198                _ => return Err(ArtifactValidationError::CanonicalApprovalMismatch),
199            }
200        }
201        Ok(resolutions)
202    }
203
204    #[allow(clippy::too_many_arguments)]
205    fn apply_request(
206        &mut self,
207        session_id: SessionId,
208        record: &RecordEnvelope,
209        request: &ApprovalRequest,
210        approval_id: ApprovalId,
211        tool_call_id: tea_protocol::ToolCallId,
212        expires_at: tea_protocol::ProtocolTimestamp,
213        contexts: &BTreeMap<ApprovalId, ApprovalContext>,
214    ) -> Result<(), ArtifactValidationError> {
215        let context = contexts
216            .get(&approval_id)
217            .ok_or(ArtifactValidationError::CanonicalApprovalMismatch)?;
218        if request.approval_id() != &approval_id
219            || request.tool_call_id() != &tool_call_id
220            || *request.session_id() != session_id
221            || request.expires_at() != expires_at
222            || request.created_at() != record.timestamp()
223            || request.profile_id() != &context.profile_id
224            || request.tool_name().as_str() != context.tool_name
225        {
226            return Err(ArtifactValidationError::CanonicalApprovalMismatch);
227        }
228        if self.requests.insert(approval_id, request.clone()).is_some() {
229            return Err(ArtifactValidationError::DuplicateApprovalArtifact);
230        }
231        Ok(())
232    }
233
234    fn apply_resolution(
235        &mut self,
236        record: &RecordEnvelope,
237        resolution: &ApprovalResolution,
238        approval_id: ApprovalId,
239        decision: tea_protocol::ApprovalDecision,
240    ) -> Result<(), ArtifactValidationError> {
241        let request = self
242            .requests
243            .get(&approval_id)
244            .ok_or(ArtifactValidationError::MissingApprovalRequest)?;
245        if resolution.request().approval_id() != &approval_id
246            || resolution.decision() != decision
247            || resolution.decided_at() != record.timestamp()
248            || request != resolution.request()
249            || !self.resolutions.insert(approval_id)
250        {
251            return Err(ArtifactValidationError::ApprovalResolutionMismatch);
252        }
253        Ok(())
254    }
255
256    fn apply_grants(
257        &mut self,
258        grants: &[GrantJournalEntry],
259        resolutions: &BTreeMap<RecordId, &ApprovalResolution>,
260    ) -> Result<(), ArtifactValidationError> {
261        let mut issued_resolution_records = BTreeSet::new();
262        for entry in grants {
263            match entry {
264                GrantJournalEntry::Issued {
265                    approval_record_id,
266                    grant,
267                } => {
268                    let resolution = resolutions
269                        .get(approval_record_id)
270                        .ok_or(ArtifactValidationError::GrantWithoutResolution)?;
271                    if resolution.issued_grant() != Some(grant) || grant.revoked_at().is_some() {
272                        return Err(ArtifactValidationError::GrantMismatch);
273                    }
274                    if self.grants.insert(grant.id(), grant.clone()).is_some() {
275                        return Err(ArtifactValidationError::DuplicateGrant);
276                    }
277                    issued_resolution_records.insert(*approval_record_id);
278                }
279                GrantJournalEntry::Revoked { grant } => self.apply_revocation(grant)?,
280            }
281        }
282        if resolutions.iter().any(|(record_id, resolution)| {
283            resolution.issued_grant().is_some() && !issued_resolution_records.contains(record_id)
284        }) {
285            return Err(ArtifactValidationError::MissingGrantJournalEntry);
286        }
287        Ok(())
288    }
289
290    fn apply_revocation(&mut self, grant: &PolicyGrant) -> Result<(), ArtifactValidationError> {
291        let issued = self
292            .grants
293            .get(&grant.id())
294            .ok_or(ArtifactValidationError::UnknownGrant)?;
295        if issued.revoked_at().is_some()
296            || grant.revoked_at().is_none()
297            || !same_grant_before_revocation(issued, grant)
298        {
299            return Err(ArtifactValidationError::InvalidRevocation);
300        }
301        self.grants.insert(grant.id(), grant.clone());
302        Ok(())
303    }
304}
305
306fn approval_contexts(
307    records: &[RecordEnvelope],
308) -> Result<BTreeMap<ApprovalId, ApprovalContext>, ArtifactValidationError> {
309    let mut reducer = SessionReducer::new();
310    let mut contexts = BTreeMap::new();
311    for record in records {
312        reducer
313            .apply(record)
314            .map_err(|_| ArtifactValidationError::InvalidCanonicalLog)?;
315        if let SessionRecord::ApprovalRequested {
316            approval_id,
317            tool_call_id,
318            ..
319        } = record.record()
320        {
321            let state = reducer
322                .state()
323                .ok_or(ArtifactValidationError::InvalidCanonicalLog)?;
324            let tool = state
325                .tool_calls()
326                .get(tool_call_id)
327                .ok_or(ArtifactValidationError::InvalidCanonicalLog)?;
328            contexts.insert(
329                *approval_id,
330                ApprovalContext {
331                    profile_id: state.configuration().profile_id().clone(),
332                    tool_name: tool.tool_name().to_owned(),
333                },
334            );
335        }
336    }
337    Ok(contexts)
338}
339
340fn same_grant_before_revocation(issued: &PolicyGrant, revoked: &PolicyGrant) -> bool {
341    let (Ok(mut issued_value), Ok(mut revoked_value)) =
342        (serde_json::to_value(issued), serde_json::to_value(revoked))
343    else {
344        return false;
345    };
346    issued_value["revokedAt"] = serde_json::Value::Null;
347    revoked_value["revokedAt"] = serde_json::Value::Null;
348    issued_value == revoked_value
349}
350
351/// Typed approval/grant persistence invariant failure.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
353pub enum ArtifactValidationError {
354    /// Canonical source records do not form a valid session log.
355    #[error("approval artifact canonical session log is invalid")]
356    InvalidCanonicalLog,
357    /// The linked canonical record is absent from durable history.
358    #[error("approval artifact canonical record is missing")]
359    MissingCanonicalRecord,
360    /// Rich request and canonical request fields differ.
361    #[error("approval artifact does not match canonical transition")]
362    CanonicalApprovalMismatch,
363    /// Rich resolution has no prior matching rich request.
364    #[error("approval resolution artifact has no matching request")]
365    MissingApprovalRequest,
366    /// A rich request identity was persisted more than once.
367    #[error("approval request artifact is duplicated")]
368    DuplicateApprovalArtifact,
369    /// Rich resolution snapshot differs from its stored request.
370    #[error("approval resolution artifact does not match request")]
371    ApprovalResolutionMismatch,
372    /// Grant issuance is not linked to a rich approval resolution in this transaction.
373    #[error("grant issuance has no matching approval resolution")]
374    GrantWithoutResolution,
375    /// Rich resolution issued a grant without the matching journal fact.
376    #[error("approval resolution grant is missing from the grant journal")]
377    MissingGrantJournalEntry,
378    /// Issued grant differs from the validated approval resolution.
379    #[error("issued grant does not match approval resolution")]
380    GrantMismatch,
381    /// Grant identity was issued more than once.
382    #[error("grant identity is duplicated")]
383    DuplicateGrant,
384    /// Revocation references a grant that was never issued.
385    #[error("grant revocation references an unknown grant")]
386    UnknownGrant,
387    /// Revoked value is missing or changes immutable grant fields.
388    #[error("grant revocation is invalid")]
389    InvalidRevocation,
390}
391
392impl ArtifactValidationError {
393    /// Returns stable storage-facing classification.
394    #[must_use]
395    pub const fn store_code(self) -> SessionStoreErrorCode {
396        match self {
397            Self::MissingCanonicalRecord
398            | Self::MissingApprovalRequest
399            | Self::GrantWithoutResolution
400            | Self::UnknownGrant => SessionStoreErrorCode::InvalidReference,
401            Self::InvalidCanonicalLog
402            | Self::CanonicalApprovalMismatch
403            | Self::DuplicateApprovalArtifact
404            | Self::ApprovalResolutionMismatch
405            | Self::MissingGrantJournalEntry
406            | Self::GrantMismatch
407            | Self::DuplicateGrant
408            | Self::InvalidRevocation => SessionStoreErrorCode::InvalidRecord,
409        }
410    }
411}