Skip to main content

car_engine/
admission.rs

1//! Proposal-admission gates — the executor's pre-execution safety seam
2//! (EPIC A, task A1).
3//!
4//! CAR ships a large suite of *verified* safety checks as pure library
5//! functions — information-flow analysis (`car_verify::check_information_flow`),
6//! concurrency-anomaly detection (`car_verify::analyze_concurrency`),
7//! tool-receipt grounding (`car_eventlog::verify_tool_receipts`), policy
8//! enforcement (`car_policy`). Historically none of them were *called* by
9//! the runtime on a normal proposal: a check was only as good as the
10//! consumer who remembered to invoke it.
11//!
12//! This module is the single place those checks attach to the live
13//! [`crate::Runtime`]. An [`AdmissionGate`] inspects a proposal *before any
14//! action runs* and returns a [`GateOutcome`]. The executor runs every
15//! registered gate during proposal admission (right after the existing
16//! transactional pre-check), aggregates the verdicts, and refuses to
17//! execute a proposal that any gate blocks or parks for approval.
18//!
19//! A1 establishes the seam and the aggregation contract; the individual
20//! gates (information-flow → A4, concurrency → A5, blocking-policy → A9)
21//! are thin [`AdmissionGate`] implementations layered on top, and the
22//! approval routing for [`GateOutcome::NeedsApproval`] is wired in A7.
23//!
24//! The default gate list is empty, so a `Runtime` that registers no gates
25//! behaves exactly as before — this is purely additive.
26
27use crate::scope::RuntimeScope;
28use car_ir::ActionProposal;
29use serde_json::Value;
30use std::collections::{HashMap, HashSet};
31
32/// Read-only context handed to each gate at admission time.
33///
34/// It carries the cheap, always-available facts a gate needs to reason
35/// about a proposal without reaching back into the `Runtime` (which would
36/// create a borrow cycle, since gates are *stored on* the runtime). Gates
37/// that need richer inputs — per-tool information-flow labels (A3/A4), the
38/// timestamped multi-agent schedule (A5) — hold those as their own state,
39/// captured when the gate is constructed.
40pub struct GateContext<'a> {
41    /// The session the proposal executes under, if any. Lets a gate apply
42    /// session-scoped rules on top of global ones, mirroring the
43    /// per-session policy registries.
44    pub session_id: Option<&'a str>,
45    /// The caller/tenant identity attached to this execution (car#187).
46    pub scope: Option<&'a RuntimeScope>,
47    /// A snapshot of shared state at admission time.
48    pub state: &'a HashMap<String, Value>,
49    /// Per-key version counters for the same snapshot — the input the
50    /// transactional / information-flow checks reason over.
51    pub versions: &'a HashMap<String, u64>,
52}
53
54/// The verdict a single [`AdmissionGate`] returns for a proposal.
55#[derive(Debug, Clone)]
56pub enum GateOutcome {
57    /// The gate raises no objection. Execution may proceed (subject to the
58    /// other gates and the normal per-action validation/policy pipeline).
59    Allow,
60    /// The gate forbids execution. `blocked` names the offending action
61    /// ids (empty means "the proposal as a whole"); `reason` is the
62    /// human-readable explanation surfaced on the rejected results and the
63    /// audit log. A rejected proposal does not run *any* action — a safety
64    /// hazard is a property of the action set, not an isolated action, the
65    /// same stance the transactional pre-check takes.
66    Reject {
67        blocked: HashSet<String>,
68        reason: String,
69    },
70    /// The gate would allow the proposal only with human approval.
71    /// `fingerprint` is the stable identity an operator approves/rejects
72    /// against (so a prior decision sticks); `actions` names the actions
73    /// that triggered the escalation.
74    ///
75    /// Until the durable approval transport is wired (A7), the executor
76    /// treats this as a block with an explanatory reason — fail-closed,
77    /// never fail-open. A7 replaces that with a real pending-approval that
78    /// resolves through the `permission.*` surface.
79    NeedsApproval {
80        actions: HashSet<String>,
81        fingerprint: String,
82        reason: String,
83    },
84}
85
86impl GateOutcome {
87    /// Convenience constructor for a whole-proposal rejection.
88    pub fn reject_all(reason: impl Into<String>) -> Self {
89        GateOutcome::Reject {
90            blocked: HashSet::new(),
91            reason: reason.into(),
92        }
93    }
94
95    /// Convenience constructor for rejecting specific actions.
96    pub fn reject_actions<I, S>(blocked: I, reason: impl Into<String>) -> Self
97    where
98        I: IntoIterator<Item = S>,
99        S: Into<String>,
100    {
101        GateOutcome::Reject {
102            blocked: blocked.into_iter().map(Into::into).collect(),
103            reason: reason.into(),
104        }
105    }
106
107    /// True when this outcome permits execution.
108    pub fn is_allow(&self) -> bool {
109        matches!(self, GateOutcome::Allow)
110    }
111
112    /// The serde-stable label for the audit event (`allow` / `reject` /
113    /// `needs_approval`). Kept as an explicit method rather than `Debug`
114    /// so the wire/doc contract is stable.
115    pub fn label(&self) -> &'static str {
116        match self {
117            GateOutcome::Allow => "allow",
118            GateOutcome::Reject { .. } => "reject",
119            GateOutcome::NeedsApproval { .. } => "needs_approval",
120        }
121    }
122}
123
124/// A pre-execution safety check that can veto a proposal.
125///
126/// Implementations must be cheap and side-effect-free: they run on the hot
127/// path of every admitted proposal. A gate that needs to *do* something on
128/// rejection (record an approval request, emit specialized telemetry)
129/// returns the verdict and lets the executor's admission loop handle the
130/// uniform consequences (rejection results, the `AdmissionGateDecision`
131/// event); gate-specific events are emitted by the gate itself before it
132/// returns.
133#[async_trait::async_trait]
134pub trait AdmissionGate: Send + Sync {
135    /// Short stable identifier (e.g. `"information_flow"`, `"concurrency"`)
136    /// recorded on the audit event so a denial is attributable to a gate.
137    fn name(&self) -> &str;
138
139    /// Inspect a proposal and return a verdict. Must not mutate shared
140    /// state — admission runs before the execution snapshot is taken.
141    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome;
142}
143
144/// One gate's approval escalation, carrying its own fingerprint. Every
145/// escalation must be individually approved for the proposal to run —
146/// a single approved fingerprint must never clear another gate's
147/// objection.
148#[derive(Debug, Clone)]
149pub struct AdmissionEscalation {
150    /// The escalating gate's name.
151    pub gate: String,
152    /// The stable identity an operator approves/rejects against.
153    pub fingerprint: String,
154    /// The gate's human-readable reason.
155    pub reason: String,
156    /// The action ids that triggered this escalation.
157    pub actions: HashSet<String>,
158}
159
160/// The aggregate decision after running every registered gate.
161///
162/// Aggregation is conjunctive and fail-closed: the proposal is admitted
163/// only if *every* gate allowed it. The first blocking gate's reason and
164/// name are surfaced as the primary cause; all blocked action ids across
165/// gates are unioned so the rejected results name every offending action.
166///
167/// Two orthogonal severities are tracked:
168/// - `hard_rejected`: at least one gate returned a hard `Reject`. Never
169///   overridable — no ledger approval (however old or broad) may clear it.
170/// - `escalations`: every `NeedsApproval` outcome, **each with its own
171///   fingerprint**. The executor admits only when *all* of them resolve
172///   to an operator approval; one pending or rejected fingerprint keeps
173///   the proposal blocked (fail-closed).
174#[derive(Debug, Clone, Default)]
175pub struct AdmissionDecision {
176    /// True when no gate objected and execution may proceed.
177    pub admitted: bool,
178    /// Union of action ids any gate blocked. Empty with `admitted == false`
179    /// means a whole-proposal rejection.
180    pub blocked: HashSet<String>,
181    /// The name of the first gate that objected, for attribution.
182    pub deciding_gate: Option<String>,
183    /// The first objecting gate's human-readable reason.
184    pub reason: Option<String>,
185    /// True when at least one gate hard-rejected. A hard reject is never
186    /// resolvable via the approval ledger.
187    pub hard_rejected: bool,
188    /// Every approval escalation from every gate, in gate order. All of
189    /// them must be approved for the proposal to run.
190    pub escalations: Vec<AdmissionEscalation>,
191}
192
193impl AdmissionDecision {
194    /// The all-clear decision.
195    pub fn admit() -> Self {
196        AdmissionDecision {
197            admitted: true,
198            ..Default::default()
199        }
200    }
201
202    /// True when the block consists solely of approval escalations — i.e.
203    /// resolvable by an operator, not a hard deny.
204    pub fn needs_approval(&self) -> bool {
205        !self.hard_rejected && !self.escalations.is_empty()
206    }
207
208    /// Fold a single gate's outcome into the running decision. Once a
209    /// blocking outcome is recorded, later allowing gates can't un-block
210    /// it; later blocking gates still contribute their action ids to the
211    /// union and their escalations to the list (so the rejection report
212    /// is complete and every escalation keeps its own fingerprint).
213    pub fn absorb(&mut self, gate_name: &str, outcome: GateOutcome) {
214        match outcome {
215            GateOutcome::Allow => {}
216            GateOutcome::Reject { blocked, reason } => {
217                self.blocked.extend(blocked);
218                self.hard_rejected = true;
219                if self.admitted {
220                    self.admitted = false;
221                    self.deciding_gate = Some(gate_name.to_string());
222                    self.reason = Some(reason);
223                } else if self.deciding_gate.is_none() {
224                    self.deciding_gate = Some(gate_name.to_string());
225                    self.reason = Some(reason);
226                }
227            }
228            GateOutcome::NeedsApproval {
229                actions,
230                fingerprint,
231                reason,
232            } => {
233                self.blocked.extend(actions.clone());
234                self.escalations.push(AdmissionEscalation {
235                    gate: gate_name.to_string(),
236                    fingerprint,
237                    reason: reason.clone(),
238                    actions,
239                });
240                if self.admitted {
241                    self.admitted = false;
242                    self.deciding_gate = Some(gate_name.to_string());
243                    self.reason = Some(reason);
244                }
245            }
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn empty_decision_admits() {
256        let d = AdmissionDecision::admit();
257        assert!(d.admitted);
258        assert!(d.blocked.is_empty());
259    }
260
261    #[test]
262    fn reject_marks_first_gate_and_unions_actions() {
263        let mut d = AdmissionDecision::admit();
264        d.absorb("flow", GateOutcome::reject_actions(["a1"], "exfil"));
265        d.absorb("concurrency", GateOutcome::reject_actions(["a2"], "stale"));
266        assert!(!d.admitted);
267        // First objecting gate is the attributed cause.
268        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
269        assert_eq!(d.reason.as_deref(), Some("exfil"));
270        // Both gates' actions are reported.
271        assert!(d.blocked.contains("a1"));
272        assert!(d.blocked.contains("a2"));
273        assert!(!d.needs_approval());
274        assert!(d.hard_rejected);
275    }
276
277    #[test]
278    fn allow_after_reject_stays_rejected() {
279        let mut d = AdmissionDecision::admit();
280        d.absorb("flow", GateOutcome::reject_all("nope"));
281        d.absorb("other", GateOutcome::Allow);
282        assert!(!d.admitted);
283        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
284    }
285
286    #[test]
287    fn needs_approval_is_tracked() {
288        let mut d = AdmissionDecision::admit();
289        d.absorb(
290            "permission",
291            GateOutcome::NeedsApproval {
292                actions: ["a1".to_string()].into_iter().collect(),
293                fingerprint: "fp123".to_string(),
294                reason: "tier escalation".to_string(),
295            },
296        );
297        assert!(!d.admitted);
298        assert!(d.needs_approval());
299        assert_eq!(d.escalations.len(), 1);
300        assert_eq!(d.escalations[0].fingerprint, "fp123");
301        assert_eq!(d.escalations[0].gate, "permission");
302    }
303
304    #[test]
305    fn every_escalation_keeps_its_own_fingerprint() {
306        // C-1 regression: a second gate's escalation must not be
307        // swallowed by the first — each carries its own fingerprint and
308        // the executor requires ALL of them approved.
309        let mut d = AdmissionDecision::admit();
310        d.absorb(
311            "flow",
312            GateOutcome::NeedsApproval {
313                actions: ["a1".to_string()].into_iter().collect(),
314                fingerprint: "fp-flow".to_string(),
315                reason: "flow hazard".to_string(),
316            },
317        );
318        d.absorb(
319            "skill_ceiling",
320            GateOutcome::NeedsApproval {
321                actions: ["a2".to_string()].into_iter().collect(),
322                fingerprint: "fp-ceiling".to_string(),
323                reason: "over ceiling".to_string(),
324            },
325        );
326        assert!(d.needs_approval());
327        let fps: Vec<&str> = d
328            .escalations
329            .iter()
330            .map(|e| e.fingerprint.as_str())
331            .collect();
332        assert_eq!(fps, vec!["fp-flow", "fp-ceiling"]);
333    }
334
335    #[test]
336    fn hard_reject_dominates_escalations() {
337        // C-1 regression: once any gate hard-rejects, the decision is not
338        // approval-resolvable, regardless of gate order.
339        let mut d = AdmissionDecision::admit();
340        d.absorb(
341            "flow",
342            GateOutcome::NeedsApproval {
343                actions: ["a1".to_string()].into_iter().collect(),
344                fingerprint: "fp-flow".to_string(),
345                reason: "flow hazard".to_string(),
346            },
347        );
348        d.absorb("rules", GateOutcome::reject_actions(["a2"], "deny rule"));
349        assert!(d.hard_rejected);
350        assert!(
351            !d.needs_approval(),
352            "a hard reject is never approval-resolvable"
353        );
354    }
355
356    #[test]
357    fn outcome_labels_are_stable() {
358        assert_eq!(GateOutcome::Allow.label(), "allow");
359        assert_eq!(GateOutcome::reject_all("x").label(), "reject");
360    }
361}