1use crate::SurfaceMetadata;
8use chrono::{DateTime, Utc};
9use parking_lot::RwLock;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13use std::str::FromStr;
14use std::sync::Arc;
15use uuid::Uuid;
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(transparent)]
21pub struct ApprovalId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
22
23impl ApprovalId {
24 #[must_use]
25 pub fn new() -> Self {
26 Self(Uuid::new_v4())
27 }
28}
29
30impl Default for ApprovalId {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl fmt::Display for ApprovalId {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 self.0.fmt(f)
39 }
40}
41
42impl FromStr for ApprovalId {
43 type Err = uuid::Error;
44
45 fn from_str(value: &str) -> Result<Self, Self::Err> {
46 Ok(Self(Uuid::parse_str(value)?))
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(transparent)]
54pub struct ApprovalPrincipalId(String);
55
56impl ApprovalPrincipalId {
57 pub fn new(value: impl Into<String>) -> Result<Self, ApprovalError> {
58 let value = value.into();
59 if value.trim().is_empty() {
60 return Err(ApprovalError::InvalidPrincipal);
61 }
62 Ok(Self(value))
63 }
64
65 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69}
70
71impl fmt::Display for ApprovalPrincipalId {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 self.0.fmt(f)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[serde(tag = "owner_type", rename_all = "snake_case")]
81pub enum ApprovalOwnerRef {
82 Runtime,
83 Session { session_id: String },
84 Mob { mob_id: String },
85 Run { run_id: String },
86 ToolCall { tool_call_id: String },
87 ExternalMember { mob_id: String, member_ref: String },
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[serde(rename_all = "snake_case")]
94pub enum ApprovalResourceKind {
95 File,
96 ShellCommand,
97 ToolCall,
98 Device,
99 Runtime,
100 Network,
101 Other,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub struct ApprovalResourceRef {
108 pub kind: ApprovalResourceKind,
109 pub id: String,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[serde(rename_all = "snake_case")]
116pub enum ApprovalActionKind {
117 ShellCommand,
118 FileWrite,
119 FileDelete,
120 NetworkCall,
121 DeviceControl,
122 ToolCall,
123 Other,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129pub struct ApprovalProposedAction {
130 pub kind: ApprovalActionKind,
131 pub summary: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub body: Option<serde_json::Value>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139#[serde(rename_all = "snake_case")]
140pub enum ApprovalRisk {
141 Low,
142 Medium,
143 High,
144 Critical,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150#[serde(rename_all = "snake_case")]
151pub enum ApprovalDecision {
152 Approve,
153 Deny,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159#[serde(rename_all = "snake_case")]
160pub enum ApprovalStatus {
161 Pending,
162 Approved,
163 Denied,
164 Expired,
165 Cancelled,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171pub struct ApprovalDecisionRecord {
172 pub decision: ApprovalDecision,
173 pub actor: ApprovalPrincipalId,
174 #[cfg_attr(feature = "schema", schemars(with = "String"))]
175 pub decided_at: DateTime<Utc>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub reason: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub provenance: Option<serde_json::Value>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185pub struct ApprovalRecord {
186 pub approval_id: ApprovalId,
187 pub status: ApprovalStatus,
188 pub requester: ApprovalPrincipalId,
189 pub owner: ApprovalOwnerRef,
190 pub resource: ApprovalResourceRef,
191 pub proposed_action: ApprovalProposedAction,
192 pub risk: ApprovalRisk,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub request_body: Option<serde_json::Value>,
195 pub allowed_decisions: BTreeSet<ApprovalDecision>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
198 pub expires_at: Option<DateTime<Utc>>,
199 #[cfg_attr(feature = "schema", schemars(with = "String"))]
200 pub created_at: DateTime<Utc>,
201 #[cfg_attr(feature = "schema", schemars(with = "String"))]
202 pub updated_at: DateTime<Utc>,
203 pub metadata: SurfaceMetadata,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub request_provenance: Option<serde_json::Value>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub decision: Option<ApprovalDecisionRecord>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213pub struct ApprovalRequest {
214 pub requester: ApprovalPrincipalId,
215 pub owner: ApprovalOwnerRef,
216 pub resource: ApprovalResourceRef,
217 pub proposed_action: ApprovalProposedAction,
218 pub risk: ApprovalRisk,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub request_body: Option<serde_json::Value>,
221 pub allowed_decisions: BTreeSet<ApprovalDecision>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
224 pub expires_at: Option<DateTime<Utc>>,
225 #[serde(default, skip_serializing_if = "SurfaceMetadata::is_empty")]
226 pub metadata: SurfaceMetadata,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub request_provenance: Option<serde_json::Value>,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct ApprovalListFilter {
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub status: Option<ApprovalStatus>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
241pub enum ApprovalError {
242 #[error("approval not found: {approval_id}")]
243 NotFound { approval_id: ApprovalId },
244 #[error("approval has already been decided: {approval_id}")]
245 AlreadyDecided { approval_id: ApprovalId },
246 #[error("approval is expired: {approval_id}")]
247 Expired { approval_id: ApprovalId },
248 #[error("decision is not allowed for approval: {decision:?}")]
249 InvalidDecision { decision: ApprovalDecision },
250 #[error("approval request must allow at least one decision")]
251 EmptyAllowedDecisions,
252 #[error("approval principal id must not be empty")]
253 InvalidPrincipal,
254 #[error(transparent)]
255 InvalidMetadata(#[from] crate::SurfaceMetadataError),
256 #[error("approval store error: {0}")]
257 Store(String),
258}
259
260pub trait ApprovalStore: Send + Sync {
265 fn load_all(&self) -> Result<Vec<ApprovalRecord>, ApprovalStoreError>;
266 fn put(&self, record: &ApprovalRecord) -> Result<(), ApprovalStoreError>;
267 fn is_persistent(&self) -> bool;
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
272pub enum ApprovalStoreError {
273 #[error("{0}")]
274 Backend(String),
275}
276
277impl From<ApprovalStoreError> for ApprovalError {
278 fn from(value: ApprovalStoreError) -> Self {
279 Self::Store(value.to_string())
280 }
281}
282
283#[derive(Debug, Default)]
285pub struct InMemoryApprovalStore {
286 records: RwLock<BTreeMap<ApprovalId, ApprovalRecord>>,
287}
288
289impl InMemoryApprovalStore {
290 #[must_use]
291 pub fn new() -> Self {
292 Self::default()
293 }
294}
295
296impl ApprovalStore for InMemoryApprovalStore {
297 fn load_all(&self) -> Result<Vec<ApprovalRecord>, ApprovalStoreError> {
298 Ok(self.records.read().values().cloned().collect())
299 }
300
301 fn put(&self, record: &ApprovalRecord) -> Result<(), ApprovalStoreError> {
302 self.records
303 .write()
304 .insert(record.approval_id.clone(), record.clone());
305 Ok(())
306 }
307
308 fn is_persistent(&self) -> bool {
309 false
310 }
311}
312
313#[derive(Clone)]
315pub struct ApprovalService {
316 records: Arc<RwLock<BTreeMap<ApprovalId, ApprovalRecord>>>,
317 store: Arc<dyn ApprovalStore>,
318}
319
320impl ApprovalService {
321 #[must_use]
322 pub fn new() -> Self {
323 Self {
324 records: Arc::new(RwLock::new(BTreeMap::new())),
325 store: Arc::new(InMemoryApprovalStore::new()),
326 }
327 }
328
329 pub fn with_store(store: Arc<dyn ApprovalStore>) -> Result<Self, ApprovalError> {
330 let records = store
331 .load_all()?
332 .into_iter()
333 .map(|record| (record.approval_id.clone(), record))
334 .collect();
335 Ok(Self {
336 records: Arc::new(RwLock::new(records)),
337 store,
338 })
339 }
340
341 #[must_use]
342 pub fn is_persistent(&self) -> bool {
343 self.store.is_persistent()
344 }
345
346 pub fn request(&self, request: ApprovalRequest) -> Result<ApprovalRecord, ApprovalError> {
347 if request.allowed_decisions.is_empty() {
348 return Err(ApprovalError::EmptyAllowedDecisions);
349 }
350 if request.requester.as_str().trim().is_empty() {
351 return Err(ApprovalError::InvalidPrincipal);
352 }
353 request.metadata.validate_public()?;
354 let now = Utc::now();
355 let record = ApprovalRecord {
356 approval_id: ApprovalId::new(),
357 status: ApprovalStatus::Pending,
358 requester: request.requester,
359 owner: request.owner,
360 resource: request.resource,
361 proposed_action: request.proposed_action,
362 risk: request.risk,
363 request_body: request.request_body,
364 allowed_decisions: request.allowed_decisions,
365 expires_at: request.expires_at,
366 created_at: now,
367 updated_at: now,
368 metadata: request.metadata,
369 request_provenance: request.request_provenance,
370 decision: None,
371 };
372 self.store.put(&record)?;
373 self.records
374 .write()
375 .insert(record.approval_id.clone(), record.clone());
376 Ok(record)
377 }
378
379 pub fn get(&self, approval_id: &ApprovalId) -> Result<ApprovalRecord, ApprovalError> {
380 self.refresh_expiry(approval_id)?;
381 self.records
382 .read()
383 .get(approval_id)
384 .cloned()
385 .ok_or_else(|| ApprovalError::NotFound {
386 approval_id: approval_id.clone(),
387 })
388 }
389
390 pub fn list(&self, filter: ApprovalListFilter) -> Result<Vec<ApprovalRecord>, ApprovalError> {
391 self.refresh_all_expiry()?;
392 Ok(self
393 .records
394 .read()
395 .values()
396 .filter(|record| filter.status.is_none_or(|status| record.status == status))
397 .cloned()
398 .collect())
399 }
400
401 pub fn decide(
402 &self,
403 approval_id: &ApprovalId,
404 decision: ApprovalDecision,
405 actor: ApprovalPrincipalId,
406 reason: Option<String>,
407 provenance: Option<serde_json::Value>,
408 ) -> Result<ApprovalRecord, ApprovalError> {
409 if actor.as_str().trim().is_empty() {
410 return Err(ApprovalError::InvalidPrincipal);
411 }
412 let now = Utc::now();
413 let mut records = self.records.write();
414 let record = records
415 .get(approval_id)
416 .cloned()
417 .ok_or_else(|| ApprovalError::NotFound {
418 approval_id: approval_id.clone(),
419 })?;
420
421 if record.status == ApprovalStatus::Pending
422 && record
423 .expires_at
424 .is_some_and(|expires_at| expires_at <= now)
425 {
426 let mut expired_record = record;
427 expired_record.status = ApprovalStatus::Expired;
428 expired_record.updated_at = now;
429 self.store.put(&expired_record)?;
430 records.insert(approval_id.clone(), expired_record);
431 return Err(ApprovalError::Expired {
432 approval_id: approval_id.clone(),
433 });
434 }
435
436 match record.status {
437 ApprovalStatus::Pending => {}
438 ApprovalStatus::Expired => {
439 return Err(ApprovalError::Expired {
440 approval_id: approval_id.clone(),
441 });
442 }
443 ApprovalStatus::Approved | ApprovalStatus::Denied | ApprovalStatus::Cancelled => {
444 return Err(ApprovalError::AlreadyDecided {
445 approval_id: approval_id.clone(),
446 });
447 }
448 }
449
450 if !record.allowed_decisions.contains(&decision) {
451 return Err(ApprovalError::InvalidDecision { decision });
452 }
453
454 let mut decided_record = record;
455 decided_record.status = match decision {
456 ApprovalDecision::Approve => ApprovalStatus::Approved,
457 ApprovalDecision::Deny => ApprovalStatus::Denied,
458 };
459 decided_record.updated_at = now;
460 decided_record.decision = Some(ApprovalDecisionRecord {
461 decision,
462 actor,
463 decided_at: now,
464 reason,
465 provenance,
466 });
467 self.store.put(&decided_record)?;
468 records.insert(approval_id.clone(), decided_record.clone());
469 Ok(decided_record)
470 }
471
472 fn refresh_expiry(&self, approval_id: &ApprovalId) -> Result<(), ApprovalError> {
473 let now = Utc::now();
474 let mut records = self.records.write();
475 if let Some(record) = records.get(approval_id).cloned()
476 && record.status == ApprovalStatus::Pending
477 && record
478 .expires_at
479 .is_some_and(|expires_at| expires_at <= now)
480 {
481 let mut expired_record = record;
482 expired_record.status = ApprovalStatus::Expired;
483 expired_record.updated_at = now;
484 self.store.put(&expired_record)?;
485 records.insert(approval_id.clone(), expired_record);
486 }
487 Ok(())
488 }
489
490 fn refresh_all_expiry(&self) -> Result<(), ApprovalError> {
491 let ids = self.records.read().keys().cloned().collect::<Vec<_>>();
492 for id in ids {
493 self.refresh_expiry(&id)?;
494 }
495 Ok(())
496 }
497}
498
499impl Default for ApprovalService {
500 fn default() -> Self {
501 Self::new()
502 }
503}
504
505#[cfg(test)]
506#[allow(clippy::expect_used)]
507mod tests {
508 use super::*;
509 use chrono::Duration;
510 use serde_json::json;
511 use std::sync::atomic::{AtomicUsize, Ordering};
512
513 #[derive(Debug)]
514 struct TestApprovalStore {
515 records: RwLock<BTreeMap<ApprovalId, ApprovalRecord>>,
516 put_calls: AtomicUsize,
517 fail_on_put_call: Option<usize>,
518 }
519
520 impl TestApprovalStore {
521 fn new(fail_on_put_call: Option<usize>) -> Self {
522 Self {
523 records: RwLock::new(BTreeMap::new()),
524 put_calls: AtomicUsize::new(0),
525 fail_on_put_call,
526 }
527 }
528
529 fn record(&self, approval_id: &ApprovalId) -> Option<ApprovalRecord> {
530 self.records.read().get(approval_id).cloned()
531 }
532 }
533
534 impl ApprovalStore for TestApprovalStore {
535 fn load_all(&self) -> Result<Vec<ApprovalRecord>, ApprovalStoreError> {
536 Ok(self.records.read().values().cloned().collect())
537 }
538
539 fn put(&self, record: &ApprovalRecord) -> Result<(), ApprovalStoreError> {
540 let put_call = self.put_calls.fetch_add(1, Ordering::SeqCst) + 1;
541 if self
542 .fail_on_put_call
543 .is_some_and(|fail_on_put_call| fail_on_put_call == put_call)
544 {
545 return Err(ApprovalStoreError::Backend(
546 "injected approval store failure".to_string(),
547 ));
548 }
549 self.records
550 .write()
551 .insert(record.approval_id.clone(), record.clone());
552 Ok(())
553 }
554
555 fn is_persistent(&self) -> bool {
556 true
557 }
558 }
559
560 fn principal(value: &str) -> ApprovalPrincipalId {
561 ApprovalPrincipalId::new(value).expect("valid principal")
562 }
563
564 fn request_with_allowed(allowed_decisions: BTreeSet<ApprovalDecision>) -> ApprovalRequest {
565 ApprovalRequest {
566 requester: principal("human:alice"),
567 owner: ApprovalOwnerRef::Session {
568 session_id: "session-1".to_string(),
569 },
570 resource: ApprovalResourceRef {
571 kind: ApprovalResourceKind::ShellCommand,
572 id: "shell:rm".to_string(),
573 },
574 proposed_action: ApprovalProposedAction {
575 kind: ApprovalActionKind::ShellCommand,
576 summary: "run destructive command".to_string(),
577 body: Some(json!({"cmd": "rm -rf target/tmp"})),
578 },
579 risk: ApprovalRisk::High,
580 request_body: Some(json!({"why": "cleanup"})),
581 allowed_decisions,
582 expires_at: None,
583 metadata: SurfaceMetadata::default(),
584 request_provenance: Some(json!({"tool_call_id": "call-1"})),
585 }
586 }
587
588 fn request() -> ApprovalRequest {
589 request_with_allowed(BTreeSet::from([
590 ApprovalDecision::Approve,
591 ApprovalDecision::Deny,
592 ]))
593 }
594
595 #[test]
596 fn approval_request_creates_pending_auditable_record() {
597 let service = ApprovalService::new();
598 let record = service.request(request()).expect("request accepted");
599 assert_eq!(record.status, ApprovalStatus::Pending);
600 assert_eq!(record.requester.as_str(), "human:alice");
601 assert_eq!(
602 record.request_provenance,
603 Some(json!({"tool_call_id": "call-1"}))
604 );
605 assert!(record.decision.is_none());
606 }
607
608 #[test]
609 fn decide_preserves_request_provenance_and_records_decision_audit() {
610 let service = ApprovalService::new();
611 let record = service.request(request()).expect("request accepted");
612 let decided = service
613 .decide(
614 &record.approval_id,
615 ApprovalDecision::Approve,
616 principal("human:bob"),
617 Some("looks intentional".to_string()),
618 Some(json!({"client": "mobile"})),
619 )
620 .expect("decision accepted");
621
622 assert_eq!(decided.status, ApprovalStatus::Approved);
623 assert_eq!(decided.request_provenance, record.request_provenance);
624 let decision = decided.decision.expect("decision audit");
625 assert_eq!(decision.actor.as_str(), "human:bob");
626 assert_eq!(decision.provenance, Some(json!({"client": "mobile"})));
627 }
628
629 #[test]
630 fn invalid_decision_is_rejected() {
631 let service = ApprovalService::new();
632 let record = service
633 .request(request_with_allowed(BTreeSet::from([
634 ApprovalDecision::Deny,
635 ])))
636 .expect("request accepted");
637 let err = service
638 .decide(
639 &record.approval_id,
640 ApprovalDecision::Approve,
641 principal("human:bob"),
642 None,
643 None,
644 )
645 .expect_err("approval should reject disallowed decision");
646 assert!(matches!(
647 err,
648 ApprovalError::InvalidDecision {
649 decision: ApprovalDecision::Approve
650 }
651 ));
652 }
653
654 #[test]
655 fn duplicate_decision_is_rejected() {
656 let service = ApprovalService::new();
657 let record = service.request(request()).expect("request accepted");
658 service
659 .decide(
660 &record.approval_id,
661 ApprovalDecision::Deny,
662 principal("human:bob"),
663 None,
664 None,
665 )
666 .expect("first decision accepted");
667 let err = service
668 .decide(
669 &record.approval_id,
670 ApprovalDecision::Deny,
671 principal("human:bob"),
672 None,
673 None,
674 )
675 .expect_err("duplicate rejected");
676 assert!(matches!(err, ApprovalError::AlreadyDecided { .. }));
677 }
678
679 #[test]
680 fn failed_decision_persist_keeps_approval_pending_for_retry() {
681 let store = Arc::new(TestApprovalStore::new(Some(2)));
682 let service = ApprovalService::with_store(store.clone()).expect("service");
683 let record = service.request(request()).expect("request accepted");
684
685 let err = service
686 .decide(
687 &record.approval_id,
688 ApprovalDecision::Approve,
689 principal("human:bob"),
690 None,
691 None,
692 )
693 .expect_err("decision write should fail");
694
695 assert!(matches!(err, ApprovalError::Store(_)));
696 let cached = service.get(&record.approval_id).expect("cached record");
697 assert_eq!(cached.status, ApprovalStatus::Pending);
698 assert!(cached.decision.is_none());
699 let persisted = store.record(&record.approval_id).expect("persisted record");
700 assert_eq!(persisted.status, ApprovalStatus::Pending);
701 assert!(persisted.decision.is_none());
702
703 let retried = service
704 .decide(
705 &record.approval_id,
706 ApprovalDecision::Deny,
707 principal("human:bob"),
708 Some("changed my mind".to_string()),
709 None,
710 )
711 .expect("retry should decide approval");
712 assert_eq!(retried.status, ApprovalStatus::Denied);
713 }
714
715 #[test]
716 fn expired_approval_cannot_be_decided() {
717 let service = ApprovalService::new();
718 let mut request = request();
719 request.expires_at = Some(Utc::now() - Duration::seconds(1));
720 let record = service.request(request).expect("request accepted");
721 let err = service
722 .decide(
723 &record.approval_id,
724 ApprovalDecision::Approve,
725 principal("human:bob"),
726 None,
727 None,
728 )
729 .expect_err("expired approval rejected");
730 assert!(matches!(err, ApprovalError::Expired { .. }));
731 assert_eq!(
732 service.get(&record.approval_id).expect("record").status,
733 ApprovalStatus::Expired
734 );
735 }
736
737 #[test]
738 fn deciding_expired_approval_persists_expiry_transition() {
739 let store = Arc::new(TestApprovalStore::new(None));
740 let service = ApprovalService::with_store(store.clone()).expect("service");
741 let mut request = request();
742 request.expires_at = Some(Utc::now() - Duration::seconds(1));
743 let record = service.request(request).expect("request accepted");
744
745 let err = service
746 .decide(
747 &record.approval_id,
748 ApprovalDecision::Approve,
749 principal("human:bob"),
750 None,
751 None,
752 )
753 .expect_err("expired approval rejected");
754
755 assert!(matches!(err, ApprovalError::Expired { .. }));
756 let persisted = store.record(&record.approval_id).expect("persisted record");
757 assert_eq!(persisted.status, ApprovalStatus::Expired);
758 assert!(persisted.decision.is_none());
759 }
760
761 #[test]
762 fn nonexistent_approval_cannot_be_decided() {
763 let service = ApprovalService::new();
764 let err = service
765 .decide(
766 &ApprovalId::new(),
767 ApprovalDecision::Approve,
768 principal("human:bob"),
769 None,
770 None,
771 )
772 .expect_err("unknown approval rejected");
773 assert!(matches!(err, ApprovalError::NotFound { .. }));
774 }
775
776 #[test]
777 fn reserved_metadata_spoofing_is_rejected() {
778 let service = ApprovalService::new();
779 let mut request = request();
780 request
781 .metadata
782 .labels
783 .insert("meerkat.approval_id".to_string(), "spoof".to_string());
784 let err = service
785 .request(request)
786 .expect_err("reserved metadata rejected");
787 assert!(matches!(
788 err,
789 ApprovalError::InvalidMetadata(crate::SurfaceMetadataError::ReservedLabelKey { .. })
790 ));
791 }
792}