Skip to main content

meerkat_core/
approval.rs

1//! Durable approval records and service contracts.
2//!
3//! The approval service owns approval record state for this first slice. Public
4//! surfaces may request, list, read, and decide approvals, but they do not own
5//! approval status transitions.
6
7use 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/// Durable approval id.
18#[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/// Principal identifier used for requester and decision actor projections.
51#[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/// Typed owner for an approval request.
78#[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/// Typed resource kind affected by an approval.
91#[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/// Resource affected by an approval.
105#[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/// Typed action kind for the proposed action.
113#[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/// Action proposed by the requester.
127#[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/// Risk classification for an approval request.
137#[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/// Allowed terminal decisions.
148#[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/// Approval record status.
157#[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/// Durable decision audit record.
169#[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/// Durable approval record.
183#[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/// Input used by tools/runtime code to request an approval.
211#[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/// Filter for listing approvals.
232#[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/// Errors from the approval service.
240#[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
260/// Durable approval store mechanics.
261///
262/// The service owns approval transitions; stores persist full records and do
263/// not decide status legality.
264pub 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/// Approval store errors, erased at the service boundary.
271#[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/// In-memory approval store for tests and process-local runtimes.
284#[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/// In-process approval service.
314#[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}