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}