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 the state this proposal may observe at admission time:
48    /// the whole store when unscoped; under a tenant, the shared keys plus
49    /// that tenant's own under their `tenant:<id>:` prefix, never another
50    /// tenant's (`StateStore::versioned_snapshot_visible_to`). Under a tenant-scoped
51    /// `scope`, a `state_write` / `state_read` / `assertion` executes against
52    /// `tenant:<id>:<key>` and a `tool_call` against the shared `<key>`, so a
53    /// gate that reads an action's keys here resolves them the same way
54    /// (`car_state::tenant_key`; car#3121).
55    pub state: &'a HashMap<String, Value>,
56    /// Per-key version counters for the same snapshot, keyed the same way —
57    /// the input the transactional / information-flow checks reason over.
58    pub versions: &'a HashMap<String, u64>,
59}
60
61/// The verdict a single [`AdmissionGate`] returns for a proposal.
62#[derive(Debug, Clone)]
63pub enum GateOutcome {
64    /// The gate raises no objection. Execution may proceed (subject to the
65    /// other gates and the normal per-action validation/policy pipeline).
66    Allow,
67    /// The gate forbids execution. `blocked` names the offending action
68    /// ids (empty means "the proposal as a whole"); `reason` is the
69    /// human-readable explanation surfaced on the rejected results and the
70    /// audit log. A rejected proposal does not run *any* action — a safety
71    /// hazard is a property of the action set, not an isolated action, the
72    /// same stance the transactional pre-check takes.
73    Reject {
74        blocked: HashSet<String>,
75        reason: String,
76    },
77    /// The gate would allow the proposal only with human approval.
78    /// `fingerprint` is the stable identity an operator approves/rejects
79    /// against (so a prior decision sticks); `actions` names the actions
80    /// that triggered the escalation.
81    ///
82    /// Until the durable approval transport is wired (A7), the executor
83    /// treats this as a block with an explanatory reason — fail-closed,
84    /// never fail-open. A7 replaces that with a real pending-approval that
85    /// resolves through the `permission.*` surface.
86    NeedsApproval {
87        actions: HashSet<String>,
88        fingerprint: String,
89        reason: String,
90    },
91}
92
93impl GateOutcome {
94    /// Convenience constructor for a whole-proposal rejection.
95    pub fn reject_all(reason: impl Into<String>) -> Self {
96        GateOutcome::Reject {
97            blocked: HashSet::new(),
98            reason: reason.into(),
99        }
100    }
101
102    /// Convenience constructor for rejecting specific actions.
103    pub fn reject_actions<I, S>(blocked: I, reason: impl Into<String>) -> Self
104    where
105        I: IntoIterator<Item = S>,
106        S: Into<String>,
107    {
108        GateOutcome::Reject {
109            blocked: blocked.into_iter().map(Into::into).collect(),
110            reason: reason.into(),
111        }
112    }
113
114    /// True when this outcome permits execution.
115    pub fn is_allow(&self) -> bool {
116        matches!(self, GateOutcome::Allow)
117    }
118
119    /// The serde-stable label for the audit event (`allow` / `reject` /
120    /// `needs_approval`). Kept as an explicit method rather than `Debug`
121    /// so the wire/doc contract is stable.
122    pub fn label(&self) -> &'static str {
123        match self {
124            GateOutcome::Allow => "allow",
125            GateOutcome::Reject { .. } => "reject",
126            GateOutcome::NeedsApproval { .. } => "needs_approval",
127        }
128    }
129}
130
131/// A pre-execution safety check that can veto a proposal.
132///
133/// Implementations must be cheap and side-effect-free: they run on the hot
134/// path of every admitted proposal. A gate that needs to *do* something on
135/// rejection (record an approval request, emit specialized telemetry)
136/// returns the verdict and lets the executor's admission loop handle the
137/// uniform consequences (rejection results, the `AdmissionGateDecision`
138/// event); gate-specific events are emitted by the gate itself before it
139/// returns.
140#[async_trait::async_trait]
141pub trait AdmissionGate: Send + Sync {
142    /// Short stable identifier (e.g. `"information_flow"`, `"concurrency"`)
143    /// recorded on the audit event so a denial is attributable to a gate.
144    fn name(&self) -> &str;
145
146    /// Inspect a proposal and return a verdict. Must not mutate shared
147    /// state — admission runs before the execution snapshot is taken.
148    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome;
149}
150
151/// One gate's approval escalation, carrying its own fingerprint. Every
152/// escalation must be individually approved for the proposal to run —
153/// a single approved fingerprint must never clear another gate's
154/// objection.
155#[derive(Debug, Clone)]
156pub struct AdmissionEscalation {
157    /// The escalating gate's name.
158    pub gate: String,
159    /// The stable identity an operator approves/rejects against.
160    pub fingerprint: String,
161    /// The gate's human-readable reason.
162    pub reason: String,
163    /// The action ids that triggered this escalation.
164    pub actions: HashSet<String>,
165}
166
167/// The aggregate decision after running every registered gate.
168///
169/// Aggregation is conjunctive and fail-closed: the proposal is admitted
170/// only if *every* gate allowed it. The first blocking gate's reason and
171/// name are surfaced as the primary cause; all blocked action ids across
172/// gates are unioned so the rejected results name every offending action.
173///
174/// Two orthogonal severities are tracked:
175/// - `hard_rejected`: at least one gate returned a hard `Reject`. Never
176///   overridable — no ledger approval (however old or broad) may clear it.
177/// - `escalations`: every `NeedsApproval` outcome, **each with its own
178///   fingerprint**. The executor admits only when *all* of them resolve
179///   to an operator approval; one pending or rejected fingerprint keeps
180///   the proposal blocked (fail-closed).
181#[derive(Debug, Clone, Default)]
182pub struct AdmissionDecision {
183    /// True when no gate objected and execution may proceed.
184    pub admitted: bool,
185    /// Union of action ids any gate blocked. Empty with `admitted == false`
186    /// means a whole-proposal rejection.
187    pub blocked: HashSet<String>,
188    /// The name of the first gate that objected, for attribution.
189    pub deciding_gate: Option<String>,
190    /// The first objecting gate's human-readable reason.
191    pub reason: Option<String>,
192    /// True when at least one gate hard-rejected. A hard reject is never
193    /// resolvable via the approval ledger.
194    pub hard_rejected: bool,
195    /// Every approval escalation from every gate, in gate order. All of
196    /// them must be approved for the proposal to run.
197    pub escalations: Vec<AdmissionEscalation>,
198}
199
200impl AdmissionDecision {
201    /// The all-clear decision.
202    pub fn admit() -> Self {
203        AdmissionDecision {
204            admitted: true,
205            ..Default::default()
206        }
207    }
208
209    /// True when the block consists solely of approval escalations — i.e.
210    /// resolvable by an operator, not a hard deny.
211    pub fn needs_approval(&self) -> bool {
212        !self.hard_rejected && !self.escalations.is_empty()
213    }
214
215    /// Fold a single gate's outcome into the running decision. Once a
216    /// blocking outcome is recorded, later allowing gates can't un-block
217    /// it; later blocking gates still contribute their action ids to the
218    /// union and their escalations to the list (so the rejection report
219    /// is complete and every escalation keeps its own fingerprint).
220    pub fn absorb(&mut self, gate_name: &str, outcome: GateOutcome) {
221        match outcome {
222            GateOutcome::Allow => {}
223            GateOutcome::Reject { blocked, reason } => {
224                self.blocked.extend(blocked);
225                self.hard_rejected = true;
226                if self.admitted {
227                    self.admitted = false;
228                    self.deciding_gate = Some(gate_name.to_string());
229                    self.reason = Some(reason);
230                } else if self.deciding_gate.is_none() {
231                    self.deciding_gate = Some(gate_name.to_string());
232                    self.reason = Some(reason);
233                }
234            }
235            GateOutcome::NeedsApproval {
236                actions,
237                fingerprint,
238                reason,
239            } => {
240                self.blocked.extend(actions.clone());
241                self.escalations.push(AdmissionEscalation {
242                    gate: gate_name.to_string(),
243                    fingerprint,
244                    reason: reason.clone(),
245                    actions,
246                });
247                if self.admitted {
248                    self.admitted = false;
249                    self.deciding_gate = Some(gate_name.to_string());
250                    self.reason = Some(reason);
251                }
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn empty_decision_admits() {
263        let d = AdmissionDecision::admit();
264        assert!(d.admitted);
265        assert!(d.blocked.is_empty());
266    }
267
268    #[test]
269    fn reject_marks_first_gate_and_unions_actions() {
270        let mut d = AdmissionDecision::admit();
271        d.absorb("flow", GateOutcome::reject_actions(["a1"], "exfil"));
272        d.absorb("concurrency", GateOutcome::reject_actions(["a2"], "stale"));
273        assert!(!d.admitted);
274        // First objecting gate is the attributed cause.
275        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
276        assert_eq!(d.reason.as_deref(), Some("exfil"));
277        // Both gates' actions are reported.
278        assert!(d.blocked.contains("a1"));
279        assert!(d.blocked.contains("a2"));
280        assert!(!d.needs_approval());
281        assert!(d.hard_rejected);
282    }
283
284    #[test]
285    fn allow_after_reject_stays_rejected() {
286        let mut d = AdmissionDecision::admit();
287        d.absorb("flow", GateOutcome::reject_all("nope"));
288        d.absorb("other", GateOutcome::Allow);
289        assert!(!d.admitted);
290        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
291    }
292
293    #[test]
294    fn needs_approval_is_tracked() {
295        let mut d = AdmissionDecision::admit();
296        d.absorb(
297            "permission",
298            GateOutcome::NeedsApproval {
299                actions: ["a1".to_string()].into_iter().collect(),
300                fingerprint: "fp123".to_string(),
301                reason: "tier escalation".to_string(),
302            },
303        );
304        assert!(!d.admitted);
305        assert!(d.needs_approval());
306        assert_eq!(d.escalations.len(), 1);
307        assert_eq!(d.escalations[0].fingerprint, "fp123");
308        assert_eq!(d.escalations[0].gate, "permission");
309    }
310
311    #[test]
312    fn every_escalation_keeps_its_own_fingerprint() {
313        // C-1 regression: a second gate's escalation must not be
314        // swallowed by the first — each carries its own fingerprint and
315        // the executor requires ALL of them approved.
316        let mut d = AdmissionDecision::admit();
317        d.absorb(
318            "flow",
319            GateOutcome::NeedsApproval {
320                actions: ["a1".to_string()].into_iter().collect(),
321                fingerprint: "fp-flow".to_string(),
322                reason: "flow hazard".to_string(),
323            },
324        );
325        d.absorb(
326            "skill_ceiling",
327            GateOutcome::NeedsApproval {
328                actions: ["a2".to_string()].into_iter().collect(),
329                fingerprint: "fp-ceiling".to_string(),
330                reason: "over ceiling".to_string(),
331            },
332        );
333        assert!(d.needs_approval());
334        let fps: Vec<&str> = d
335            .escalations
336            .iter()
337            .map(|e| e.fingerprint.as_str())
338            .collect();
339        assert_eq!(fps, vec!["fp-flow", "fp-ceiling"]);
340    }
341
342    #[test]
343    fn hard_reject_dominates_escalations() {
344        // C-1 regression: once any gate hard-rejects, the decision is not
345        // approval-resolvable, regardless of gate order.
346        let mut d = AdmissionDecision::admit();
347        d.absorb(
348            "flow",
349            GateOutcome::NeedsApproval {
350                actions: ["a1".to_string()].into_iter().collect(),
351                fingerprint: "fp-flow".to_string(),
352                reason: "flow hazard".to_string(),
353            },
354        );
355        d.absorb("rules", GateOutcome::reject_actions(["a2"], "deny rule"));
356        assert!(d.hard_rejected);
357        assert!(
358            !d.needs_approval(),
359            "a hard reject is never approval-resolvable"
360        );
361    }
362
363    #[test]
364    fn outcome_labels_are_stable() {
365        assert_eq!(GateOutcome::Allow.label(), "allow");
366        assert_eq!(GateOutcome::reject_all("x").label(), "reject");
367    }
368}