Skip to main content

car_engine/
authz.rs

1//! Authorization pipeline for tool execution.
2//!
3//! Provides a structured pre-execution decision path with typed decisions.
4//! Each stage can Allow, Deny, or AskUser, and the pipeline short-circuits
5//! on the first Deny.
6
7use car_ir::{Action, ActionType, ToolSchema};
8use car_policy::PolicyEngine;
9use car_state::StateStore;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// The stage that produced an authorization decision.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AuthzStage {
17    /// Tool existence check.
18    ToolExists,
19    /// Capability check (agent-level whitelist/blacklist).
20    Capability,
21    /// Permission mode / approval policy.
22    Permission,
23    /// Permanent restrictions (never bypassable).
24    Restriction,
25    /// Policy engine rules.
26    Policy,
27    /// Executor-level parameter validation.
28    Validation,
29}
30
31/// The authorization decision.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AuthzDecision {
35    /// Execution is allowed to proceed.
36    Allow,
37    /// Execution requires user approval before proceeding.
38    AskUser,
39    /// Execution is denied.
40    Deny,
41}
42
43/// A complete authorization result from the pipeline.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AuthzResult {
46    /// The final decision.
47    pub decision: AuthzDecision,
48    /// The stage that produced the decision (for Deny/AskUser, the stage that stopped it).
49    pub stage: AuthzStage,
50    /// Machine-readable reason code.
51    pub reason_code: String,
52    /// Human-readable explanation.
53    pub explanation: String,
54    /// Results from each stage that was evaluated.
55    pub stage_results: Vec<StageResult>,
56}
57
58/// Result from a single authorization stage.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct StageResult {
61    pub stage: AuthzStage,
62    pub decision: AuthzDecision,
63    pub reason: String,
64}
65
66impl AuthzResult {
67    pub fn allowed(stage: AuthzStage) -> Self {
68        Self {
69            decision: AuthzDecision::Allow,
70            stage,
71            reason_code: "allowed".to_string(),
72            explanation: "All authorization checks passed".to_string(),
73            stage_results: Vec::new(),
74        }
75    }
76
77    pub fn denied(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
78        Self {
79            decision: AuthzDecision::Deny,
80            stage,
81            reason_code: reason_code.to_string(),
82            explanation: explanation.to_string(),
83            stage_results: Vec::new(),
84        }
85    }
86
87    pub fn ask_user(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
88        Self {
89            decision: AuthzDecision::AskUser,
90            stage,
91            reason_code: reason_code.to_string(),
92            explanation: explanation.to_string(),
93            stage_results: Vec::new(),
94        }
95    }
96
97    fn with_stages(mut self, stages: Vec<StageResult>) -> Self {
98        self.stage_results = stages;
99        self
100    }
101}
102
103/// A permanent restriction that can never be bypassed.
104pub struct Restriction {
105    pub name: String,
106    pub description: String,
107    check: Box<dyn Fn(&Action) -> Option<String> + Send + Sync>,
108}
109
110impl Restriction {
111    pub fn new<F>(name: &str, description: &str, check: F) -> Self
112    where
113        F: Fn(&Action) -> Option<String> + Send + Sync + 'static,
114    {
115        Self {
116            name: name.to_string(),
117            description: description.to_string(),
118            check: Box::new(check),
119        }
120    }
121
122    fn check(&self, action: &Action) -> Option<String> {
123        (self.check)(action)
124    }
125}
126
127/// Callback for permission mode decisions (allow/ask/deny).
128/// Products implement this to integrate their approval UX.
129#[async_trait::async_trait]
130pub trait PermissionHandler: Send + Sync {
131    /// Decide whether to allow, ask, or deny a tool call.
132    async fn check(&self, tool_name: &str, action: &Action) -> AuthzDecision;
133}
134
135/// Default permission handler that allows everything.
136pub struct AllowAllPermissions;
137
138#[async_trait::async_trait]
139impl PermissionHandler for AllowAllPermissions {
140    async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
141        AuthzDecision::Allow
142    }
143}
144
145/// The authorization pipeline.
146pub struct AuthzPipeline {
147    restrictions: Vec<Restriction>,
148    permission_handler: Box<dyn PermissionHandler>,
149}
150
151impl AuthzPipeline {
152    pub fn new() -> Self {
153        Self {
154            restrictions: Vec::new(),
155            permission_handler: Box::new(AllowAllPermissions),
156        }
157    }
158
159    /// Add a permanent restriction.
160    pub fn add_restriction(&mut self, restriction: Restriction) {
161        self.restrictions.push(restriction);
162    }
163
164    /// Set the permission handler.
165    pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionHandler>) {
166        self.permission_handler = handler;
167    }
168
169    /// Run the full authorization pipeline for an action.
170    ///
171    /// Stages (in order):
172    /// 1. Tool exists
173    /// 2. Capability allows it
174    /// 3. Permission mode / approval
175    /// 4. Permanent restrictions
176    /// 5. Policy engine
177    /// 6. Executor-level validation
178    pub async fn authorize(
179        &self,
180        action: &Action,
181        tools: &HashMap<String, ToolSchema>,
182        capabilities: Option<&crate::capabilities::CapabilitySet>,
183        policies: &PolicyEngine,
184        state: &StateStore,
185    ) -> AuthzResult {
186        let mut stages = Vec::new();
187
188        // Stage 1: Tool exists
189        if let Some(tool_name) = &action.tool {
190            if action.action_type == ActionType::ToolCall && !tools.contains_key(tool_name) {
191                stages.push(StageResult {
192                    stage: AuthzStage::ToolExists,
193                    decision: AuthzDecision::Deny,
194                    reason: format!("tool '{}' not registered", tool_name),
195                });
196                return AuthzResult::denied(
197                    AuthzStage::ToolExists,
198                    "tool_not_found",
199                    &format!("Tool '{}' is not registered", tool_name),
200                )
201                .with_stages(stages);
202            }
203        }
204        stages.push(StageResult {
205            stage: AuthzStage::ToolExists,
206            decision: AuthzDecision::Allow,
207            reason: "tool registered".to_string(),
208        });
209
210        // Stage 2: Capability check
211        if let Some(caps) = capabilities {
212            if let Some(tool_name) = &action.tool {
213                if !caps.tool_allowed(tool_name) {
214                    stages.push(StageResult {
215                        stage: AuthzStage::Capability,
216                        decision: AuthzDecision::Deny,
217                        reason: format!("tool '{}' not in capability set", tool_name),
218                    });
219                    return AuthzResult::denied(
220                        AuthzStage::Capability,
221                        "capability_denied",
222                        &format!("Tool '{}' denied by capability set", tool_name),
223                    )
224                    .with_stages(stages);
225                }
226            }
227        }
228        stages.push(StageResult {
229            stage: AuthzStage::Capability,
230            decision: AuthzDecision::Allow,
231            reason: "capability check passed".to_string(),
232        });
233
234        // Stage 3: Permission mode
235        if let Some(tool_name) = &action.tool {
236            let perm = self.permission_handler.check(tool_name, action).await;
237            stages.push(StageResult {
238                stage: AuthzStage::Permission,
239                decision: perm,
240                reason: format!("permission handler returned {:?}", perm),
241            });
242            if perm == AuthzDecision::Deny {
243                return AuthzResult::denied(
244                    AuthzStage::Permission,
245                    "permission_denied",
246                    &format!("Permission denied for tool '{}'", tool_name),
247                )
248                .with_stages(stages);
249            }
250            if perm == AuthzDecision::AskUser {
251                return AuthzResult::ask_user(
252                    AuthzStage::Permission,
253                    "approval_required",
254                    &format!("Tool '{}' requires user approval", tool_name),
255                )
256                .with_stages(stages);
257            }
258        } else {
259            stages.push(StageResult {
260                stage: AuthzStage::Permission,
261                decision: AuthzDecision::Allow,
262                reason: "no tool name, skipped".to_string(),
263            });
264        }
265
266        // Stage 4: Permanent restrictions
267        for restriction in &self.restrictions {
268            if let Some(reason) = restriction.check(action) {
269                stages.push(StageResult {
270                    stage: AuthzStage::Restriction,
271                    decision: AuthzDecision::Deny,
272                    reason: reason.clone(),
273                });
274                return AuthzResult::denied(
275                    AuthzStage::Restriction,
276                    &format!("restriction_{}", restriction.name),
277                    &format!("Permanent restriction '{}': {}", restriction.name, reason),
278                )
279                .with_stages(stages);
280            }
281        }
282        stages.push(StageResult {
283            stage: AuthzStage::Restriction,
284            decision: AuthzDecision::Allow,
285            reason: "all restrictions passed".to_string(),
286        });
287
288        // Stage 5: Policy engine
289        let violations = policies.check(action, state);
290        if !violations.is_empty() {
291            let reasons: Vec<String> = violations
292                .iter()
293                .map(|v| format!("{}: {}", v.policy_name, v.reason))
294                .collect();
295            stages.push(StageResult {
296                stage: AuthzStage::Policy,
297                decision: AuthzDecision::Deny,
298                reason: reasons.join("; "),
299            });
300            return AuthzResult::denied(
301                AuthzStage::Policy,
302                "policy_violation",
303                &format!("Policy violations: {}", reasons.join("; ")),
304            )
305            .with_stages(stages);
306        }
307        stages.push(StageResult {
308            stage: AuthzStage::Policy,
309            decision: AuthzDecision::Allow,
310            reason: "all policies passed".to_string(),
311        });
312
313        // Stage 6: Validation (deferred to caller — we just mark it as passed here)
314        stages.push(StageResult {
315            stage: AuthzStage::Validation,
316            decision: AuthzDecision::Allow,
317            reason: "validation deferred".to_string(),
318        });
319
320        AuthzResult::allowed(AuthzStage::Validation).with_stages(stages)
321    }
322}
323
324impl Default for AuthzPipeline {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330/// Bridges a [`car_policy::PermissionGate`] into the authorization
331/// pipeline's permission stage.
332///
333/// This is where the permission-tier model (survey §3.4.3, §5.2.5) meets
334/// the existing pipeline: the gate classifies each action's risk tier,
335/// compares it to the session's granted standing authority, and consults
336/// the durable approval ledger. Its decision maps onto the pipeline's
337/// existing vocabulary — `Allow` proceeds, `NeedsApproval` becomes
338/// `AskUser` (autonomy suspended pending a human decision), and a prior
339/// rejection becomes `Deny`. Every decision is audited to the event log
340/// as a `PermissionDecision` event so the tier reasoning is inspectable
341/// rather than implicit.
342///
343/// # Two axes on one event
344///
345/// The audited event carries `reversibility` alongside `required_tier`,
346/// from `car_policy::classify_reversibility` — the independent answer to
347/// *can this be undone?* The gate itself does not consult it and its
348/// decision does not depend on it; the field is recorded because the two
349/// questions used to be fused inside `PermissionTier` and an audit trail
350/// that reports only the ladder cannot tell a `git push` (recoverable) from
351/// a charged card (not) — both arrive as `full_access` / `needs_approval`.
352/// Splitting them at the point of record is what lets a later gate, a
353/// reviewer, or a post-hoc analysis distinguish the two without re-deriving
354/// the classification from a tool name months later. See
355/// `docs/proposals/shepherd-substrate-adoption.md`.
356pub struct TierPermissionHandler {
357    gate: std::sync::Arc<tokio::sync::RwLock<car_policy::PermissionGate>>,
358    log: Option<std::sync::Arc<tokio::sync::Mutex<car_eventlog::EventLog>>>,
359}
360
361impl TierPermissionHandler {
362    pub fn new(gate: std::sync::Arc<tokio::sync::RwLock<car_policy::PermissionGate>>) -> Self {
363        Self { gate, log: None }
364    }
365
366    /// Audit each gate decision to this event log as a `PermissionDecision`.
367    pub fn with_event_log(
368        mut self,
369        log: std::sync::Arc<tokio::sync::Mutex<car_eventlog::EventLog>>,
370    ) -> Self {
371        self.log = Some(log);
372        self
373    }
374
375    /// Record a durable human-in-the-loop decision through the gate and
376    /// emit it to the event log as an `ApprovalRecorded` event — the
377    /// auditable state transition §5.2.5 calls for ("who approved/rejected
378    /// what, when, on what evidence"). `approve=false` records a rejection.
379    /// Errs when the ledger journal write fails — the decision was NOT
380    /// recorded, and no `ApprovalRecorded` event is emitted (review A7).
381    pub async fn record_approval(
382        &self,
383        action: &Action,
384        approve: bool,
385        reviewer: &str,
386        reason: &str,
387        evidence: Option<String>,
388    ) -> std::io::Result<car_policy::ApprovalRecord> {
389        let record = {
390            let mut gate = self.gate.write().await;
391            if approve {
392                gate.approve(action, reviewer, reason, evidence)?
393            } else {
394                gate.reject(action, reviewer, reason, evidence)?
395            }
396        };
397        if let Some(log) = &self.log {
398            let mut data = HashMap::new();
399            data.insert("fingerprint".into(), record.fingerprint.clone().into());
400            data.insert(
401                "approval".into(),
402                match record.decision {
403                    car_policy::ApprovalDecision::Approved => "approved",
404                    car_policy::ApprovalDecision::Rejected => "rejected",
405                }
406                .into(),
407            );
408            data.insert("required_tier".into(), record.required_tier.as_str().into());
409            data.insert("reviewer".into(), record.reviewer.clone().into());
410            data.insert("reason".into(), record.reason.clone().into());
411            if let Some(ev) = &record.evidence {
412                data.insert("evidence".into(), ev.clone().into());
413            }
414            log.lock().await.append(
415                car_eventlog::EventKind::ApprovalRecorded,
416                Some(&action.id),
417                None,
418                data,
419            );
420        }
421        Ok(record)
422    }
423
424    /// Map a gate decision onto the pipeline's decision vocabulary.
425    fn map_decision(decision: &car_policy::GateDecision) -> AuthzDecision {
426        match decision {
427            car_policy::GateDecision::Allow { .. } => AuthzDecision::Allow,
428            car_policy::GateDecision::NeedsApproval { .. } => AuthzDecision::AskUser,
429            car_policy::GateDecision::Deny { .. } => AuthzDecision::Deny,
430        }
431    }
432
433    /// Flatten a gate decision into structured event data for the audit
434    /// trail.
435    ///
436    /// `reversibility` is on every variant, including `allow`: an action that
437    /// sailed through the gate still has a rollback contract, and an audit
438    /// trail that only records the contract of the actions it *stopped* is
439    /// missing exactly the rows an incident review reads first. It is derived
440    /// from the action rather than the decision because the two axes are
441    /// independent — the gate's verdict carries no information about whether
442    /// the effect can be undone.
443    fn decision_data(
444        reversibility: car_ir::Reversibility,
445        decision: &car_policy::GateDecision,
446    ) -> HashMap<String, serde_json::Value> {
447        use car_policy::GateDecision::*;
448        let mut data = HashMap::new();
449        data.insert("reversibility".into(), reversibility.as_str().into());
450        match decision {
451            Allow { required, granted } => {
452                data.insert("gate_decision".into(), "allow".into());
453                data.insert("required_tier".into(), required.as_str().into());
454                data.insert("granted_tier".into(), granted.as_str().into());
455            }
456            NeedsApproval {
457                required,
458                granted,
459                fingerprint,
460                reason,
461            } => {
462                data.insert("gate_decision".into(), "needs_approval".into());
463                data.insert("required_tier".into(), required.as_str().into());
464                data.insert("granted_tier".into(), granted.as_str().into());
465                data.insert("fingerprint".into(), fingerprint.clone().into());
466                data.insert("reason".into(), reason.clone().into());
467            }
468            Deny {
469                required,
470                fingerprint,
471                reason,
472            } => {
473                data.insert("gate_decision".into(), "deny".into());
474                data.insert("required_tier".into(), required.as_str().into());
475                data.insert("fingerprint".into(), fingerprint.clone().into());
476                data.insert("reason".into(), reason.clone().into());
477            }
478        }
479        data
480    }
481}
482
483#[async_trait::async_trait]
484impl PermissionHandler for TierPermissionHandler {
485    async fn check(&self, _tool_name: &str, action: &Action) -> AuthzDecision {
486        // Both axes from one flatten of the parameter payload — this runs per
487        // action on the execution path, where the payload can be a whole
488        // document or diff (Parslee-ai/car#856).
489        let axes = {
490            let gate = self.gate.read().await;
491            gate.evaluate_axes(action, None, None)
492        };
493        let decision = axes.decision;
494        if let Some(log) = &self.log {
495            let data = Self::decision_data(axes.reversibility, &decision);
496            log.lock().await.append(
497                car_eventlog::EventKind::PermissionDecision,
498                Some(&action.id),
499                None,
500                data,
501            );
502        }
503        Self::map_decision(&decision)
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use car_ir::{Action, ActionType, ToolSchema};
511
512    fn test_action(tool: &str) -> Action {
513        {
514            let mut a = Action::new(ActionType::ToolCall);
515            a.id = "test-1".to_string();
516            a.tool = Some(tool.to_string());
517            a
518        }
519    }
520
521    fn test_tools() -> HashMap<String, ToolSchema> {
522        let mut m = HashMap::new();
523        m.insert(
524            "read".to_string(),
525            ToolSchema {
526                name: "read".to_string(),
527                source: car_ir::ToolSourceKind::UserDefined,
528                description: "Read a file".to_string(),
529                parameters: serde_json::json!({"type": "object"}),
530                returns: None,
531                idempotent: true,
532                cache_ttl_secs: None,
533                rate_limit: None,
534            },
535        );
536        m
537    }
538
539    #[tokio::test]
540    async fn test_allow_registered_tool() {
541        let pipeline = AuthzPipeline::new();
542        let tools = test_tools();
543        let policies = PolicyEngine::new();
544        let state = StateStore::new();
545
546        let result = pipeline
547            .authorize(&test_action("read"), &tools, None, &policies, &state)
548            .await;
549        assert_eq!(result.decision, AuthzDecision::Allow);
550        assert_eq!(result.stage_results.len(), 6);
551    }
552
553    #[tokio::test]
554    async fn test_deny_unregistered_tool() {
555        let pipeline = AuthzPipeline::new();
556        let tools = test_tools();
557        let policies = PolicyEngine::new();
558        let state = StateStore::new();
559
560        let result = pipeline
561            .authorize(&test_action("delete"), &tools, None, &policies, &state)
562            .await;
563        assert_eq!(result.decision, AuthzDecision::Deny);
564        assert_eq!(result.stage, AuthzStage::ToolExists);
565        assert_eq!(result.reason_code, "tool_not_found");
566    }
567
568    #[tokio::test]
569    async fn test_capability_denial() {
570        let pipeline = AuthzPipeline::new();
571        let tools = test_tools();
572        let policies = PolicyEngine::new();
573        let state = StateStore::new();
574        let mut caps = crate::capabilities::CapabilitySet::default();
575        caps.denied_tools.insert("read".to_string());
576
577        let result = pipeline
578            .authorize(&test_action("read"), &tools, Some(&caps), &policies, &state)
579            .await;
580        assert_eq!(result.decision, AuthzDecision::Deny);
581        assert_eq!(result.stage, AuthzStage::Capability);
582    }
583
584    #[tokio::test]
585    async fn test_restriction() {
586        let mut pipeline = AuthzPipeline::new();
587        pipeline.add_restriction(Restriction::new("no_read", "Never allow read", |action| {
588            if action.tool.as_deref() == Some("read") {
589                Some("reads are restricted".to_string())
590            } else {
591                None
592            }
593        }));
594        let tools = test_tools();
595        let policies = PolicyEngine::new();
596        let state = StateStore::new();
597
598        let result = pipeline
599            .authorize(&test_action("read"), &tools, None, &policies, &state)
600            .await;
601        assert_eq!(result.decision, AuthzDecision::Deny);
602        assert_eq!(result.stage, AuthzStage::Restriction);
603    }
604
605    #[tokio::test]
606    async fn test_policy_violation() {
607        let pipeline = AuthzPipeline::new();
608        let tools = test_tools();
609        let state = StateStore::new();
610        let mut policies = PolicyEngine::new();
611        policies.register(
612            "deny_all",
613            Box::new(|_action: &Action, _state: &StateStore| Some("denied by test".to_string())),
614            "test policy",
615        );
616
617        let result = pipeline
618            .authorize(&test_action("read"), &tools, None, &policies, &state)
619            .await;
620        assert_eq!(result.decision, AuthzDecision::Deny);
621        assert_eq!(result.stage, AuthzStage::Policy);
622    }
623
624    #[tokio::test]
625    async fn test_ask_user_permission() {
626        struct AskPermissions;
627        #[async_trait::async_trait]
628        impl PermissionHandler for AskPermissions {
629            async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
630                AuthzDecision::AskUser
631            }
632        }
633
634        let mut pipeline = AuthzPipeline::new();
635        pipeline.set_permission_handler(Box::new(AskPermissions));
636        let tools = test_tools();
637        let policies = PolicyEngine::new();
638        let state = StateStore::new();
639
640        let result = pipeline
641            .authorize(&test_action("read"), &tools, None, &policies, &state)
642            .await;
643        assert_eq!(result.decision, AuthzDecision::AskUser);
644        assert_eq!(result.stage, AuthzStage::Permission);
645        assert_eq!(result.reason_code, "approval_required");
646    }
647
648    #[tokio::test]
649    async fn test_stage_results_trace() {
650        let pipeline = AuthzPipeline::new();
651        let tools = test_tools();
652        let policies = PolicyEngine::new();
653        let state = StateStore::new();
654
655        let result = pipeline
656            .authorize(&test_action("read"), &tools, None, &policies, &state)
657            .await;
658        // All 6 stages should be present when everything passes
659        let stage_names: Vec<AuthzStage> = result.stage_results.iter().map(|s| s.stage).collect();
660        assert_eq!(
661            stage_names,
662            vec![
663                AuthzStage::ToolExists,
664                AuthzStage::Capability,
665                AuthzStage::Permission,
666                AuthzStage::Restriction,
667                AuthzStage::Policy,
668                AuthzStage::Validation,
669            ]
670        );
671    }
672
673    #[tokio::test]
674    async fn test_short_circuit_on_deny() {
675        let pipeline = AuthzPipeline::new();
676        let tools = test_tools();
677        let policies = PolicyEngine::new();
678        let state = StateStore::new();
679
680        // Unregistered tool should short-circuit at stage 1
681        let result = pipeline
682            .authorize(&test_action("nonexistent"), &tools, None, &policies, &state)
683            .await;
684        assert_eq!(result.stage_results.len(), 1);
685        assert_eq!(result.stage_results[0].stage, AuthzStage::ToolExists);
686    }
687
688    #[tokio::test]
689    async fn test_serde_roundtrip() {
690        let result = AuthzResult::denied(AuthzStage::Policy, "policy_violation", "Test violation");
691        let json = serde_json::to_string(&result).unwrap();
692        let roundtripped: AuthzResult = serde_json::from_str(&json).unwrap();
693        assert_eq!(roundtripped.decision, AuthzDecision::Deny);
694        assert_eq!(roundtripped.stage, AuthzStage::Policy);
695        assert_eq!(roundtripped.reason_code, "policy_violation");
696    }
697
698    // --- TierPermissionHandler bridge (permission tiers → pipeline) ---
699
700    use std::sync::Arc;
701    use tokio::sync::RwLock;
702
703    fn deploy_action() -> Action {
704        let mut a = test_action("deploy_service");
705        a.id = "deploy-1".to_string();
706        a
707    }
708
709    #[tokio::test]
710    async fn tier_handler_asks_user_for_full_access() {
711        // A FullAccess action under a SandboxEdit grant must escalate to
712        // the user, surfacing as AuthzDecision::AskUser through the
713        // pipeline's permission stage.
714        let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
715            car_policy::PermissionTier::SandboxEdit,
716        )));
717        let handler = TierPermissionHandler::new(gate);
718        assert_eq!(
719            handler.check("deploy_service", &deploy_action()).await,
720            AuthzDecision::AskUser
721        );
722    }
723
724    #[tokio::test]
725    async fn tier_handler_allows_after_approval_and_audits() {
726        let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
727            car_policy::PermissionTier::SandboxEdit,
728        )));
729        let log = Arc::new(tokio::sync::Mutex::new(car_eventlog::EventLog::new()));
730        let handler = TierPermissionHandler::new(gate.clone()).with_event_log(log.clone());
731        let action = deploy_action();
732
733        // First pass: escalation, audited.
734        assert_eq!(
735            handler.check("deploy_service", &action).await,
736            AuthzDecision::AskUser
737        );
738        // Human approves the operation through the handler (durable ledger
739        // entry + ApprovalRecorded audit event).
740        handler
741            .record_approval(&action, true, "matt", "reviewed", Some("diff".into()))
742            .await
743            .unwrap();
744        // Now the same operation is allowed.
745        assert_eq!(
746            handler.check("deploy_service", &action).await,
747            AuthzDecision::Allow
748        );
749
750        let log = log.lock().await;
751        // Both evaluations were audited as PermissionDecision events.
752        let decisions: Vec<_> = log
753            .events()
754            .iter()
755            .filter(|e| e.kind == car_eventlog::EventKind::PermissionDecision)
756            .collect();
757        assert_eq!(decisions.len(), 2);
758        assert_eq!(
759            decisions[0].data.get("gate_decision").unwrap(),
760            "needs_approval"
761        );
762        assert_eq!(decisions[1].data.get("gate_decision").unwrap(), "allow");
763        // The second axis rides on BOTH rows, the allow included: an action
764        // that passed the gate still has a rollback contract, and that is the
765        // row an incident review reads first.
766        for d in &decisions {
767            assert_eq!(d.data.get("reversibility").unwrap(), "compensable");
768        }
769        // The approval itself was audited as a durable state transition.
770        let approvals: Vec<_> = log
771            .events()
772            .iter()
773            .filter(|e| e.kind == car_eventlog::EventKind::ApprovalRecorded)
774            .collect();
775        assert_eq!(approvals.len(), 1);
776        assert_eq!(approvals[0].data.get("approval").unwrap(), "approved");
777        assert_eq!(approvals[0].data.get("reviewer").unwrap(), "matt");
778    }
779
780    #[tokio::test]
781    async fn tier_handler_denies_after_rejection() {
782        let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
783            car_policy::PermissionTier::FullAccess,
784        )));
785        let handler = TierPermissionHandler::new(gate.clone());
786        let action = deploy_action();
787        gate.write()
788            .await
789            .reject(&action, "matt", "not authorized", None)
790            .unwrap();
791        assert_eq!(
792            handler.check("deploy_service", &action).await,
793            AuthzDecision::Deny
794        );
795    }
796
797    #[tokio::test]
798    async fn permission_decision_events_carry_both_axes() {
799        // A deploy and an outbound email produce audit rows that are IDENTICAL
800        // on the authority ladder — full_access, escalated to a human — and
801        // differ completely in whether the effect can be taken back. Without
802        // `reversibility` on the event nothing downstream can tell them apart,
803        // which is the conflation the second axis removes.
804        let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
805            car_policy::PermissionTier::FullAccess,
806        )));
807        let log = Arc::new(tokio::sync::Mutex::new(car_eventlog::EventLog::new()));
808        let handler = TierPermissionHandler::new(gate).with_event_log(log.clone());
809
810        let deploy = deploy_action();
811        let mut email = test_action("send_email");
812        email.id = "email-1".to_string();
813
814        assert_eq!(
815            handler.check("deploy_service", &deploy).await,
816            AuthzDecision::AskUser
817        );
818        assert_eq!(
819            handler.check("send_email", &email).await,
820            AuthzDecision::AskUser
821        );
822
823        let log = log.lock().await;
824        let rows: Vec<_> = log
825            .events()
826            .iter()
827            .filter(|e| e.kind == car_eventlog::EventKind::PermissionDecision)
828            .collect();
829        assert_eq!(rows.len(), 2);
830        for row in &rows {
831            assert_eq!(row.data.get("gate_decision").unwrap(), "needs_approval");
832            assert_eq!(row.data.get("required_tier").unwrap(), "full_access");
833        }
834        // ...and this is what the ladder alone could not have said.
835        assert_eq!(rows[0].data.get("reversibility").unwrap(), "compensable");
836        assert_eq!(rows[1].data.get("reversibility").unwrap(), "irreversible");
837    }
838}