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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12#[allow(clippy::large_enum_variant)] pub enum ApprovalArtifactEntry {
14 Requested {
16 record_id: RecordId,
18 request: ApprovalRequest,
20 },
21 Resolved {
23 record_id: RecordId,
25 resolution: ApprovalResolution,
27 },
28}
29
30impl ApprovalArtifactEntry {
31 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum GrantJournalEntry {
44 Issued {
46 approval_record_id: RecordId,
48 grant: PolicyGrant,
50 },
51 Revoked {
53 grant: PolicyGrant,
55 },
56}
57
58impl GrantJournalEntry {
59 #[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#[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 #[must_use]
88 pub fn rebuild_from_journal(grant_journal: &[GrantJournalEntry]) -> Self {
89 Self::rebuild_from_journals(&[], grant_journal)
90 }
91
92 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
353pub enum ArtifactValidationError {
354 #[error("approval artifact canonical session log is invalid")]
356 InvalidCanonicalLog,
357 #[error("approval artifact canonical record is missing")]
359 MissingCanonicalRecord,
360 #[error("approval artifact does not match canonical transition")]
362 CanonicalApprovalMismatch,
363 #[error("approval resolution artifact has no matching request")]
365 MissingApprovalRequest,
366 #[error("approval request artifact is duplicated")]
368 DuplicateApprovalArtifact,
369 #[error("approval resolution artifact does not match request")]
371 ApprovalResolutionMismatch,
372 #[error("grant issuance has no matching approval resolution")]
374 GrantWithoutResolution,
375 #[error("approval resolution grant is missing from the grant journal")]
377 MissingGrantJournalEntry,
378 #[error("issued grant does not match approval resolution")]
380 GrantMismatch,
381 #[error("grant identity is duplicated")]
383 DuplicateGrant,
384 #[error("grant revocation references an unknown grant")]
386 UnknownGrant,
387 #[error("grant revocation is invalid")]
389 InvalidRevocation,
390}
391
392impl ArtifactValidationError {
393 #[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}