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}