Skip to main content

car_server_core/
supervision.rs

1//! The admission gate, published as an out-of-process subscription.
2//!
3//! CAR already blocks before execution: [`car_engine::AdmissionGate`] runs on
4//! every admitted proposal, conjunctively and fail-closed. What it could not do
5//! is let an **external** process read that stream and decide. Every gate was
6//! in-process — a Rust trait impl compiled into the daemon — so supervising a
7//! third-party agent meant forking CAR, not connecting to it.
8//!
9//! This module closes that. [`SupervisionGate`] is an `AdmissionGate` that
10//! publishes each proposal as a trimmed [`SupervisionIntent`] to subscribed WS
11//! clients and parks until one of them decides, or the timeout fires. The
12//! supervisor is any process that can hold a WebSocket — a model-driven meta
13//! agent, a rules engine, a human console.
14//!
15//! ## What this is NOT — read before extending
16//!
17//! Shepherd (arXiv 2605.10913, Appendix E) supervises with a three-verb
18//! vocabulary: `inject` (append one user message, session and tool trail
19//! intact), `handoff` (abort the session, restart on the same identity),
20//! `discard` (handoff plus roll the scope back). **None of the three are here**,
21//! and the reason is not oversight:
22//!
23//! - `inject` mutates a live conversation. This gate sits at *proposal
24//!   admission* in the engine, which has no conversation to append to. Its
25//!   natural home is the assistant loop, a different seam.
26//! - `handoff` needs session abort-and-restart as a primitive. CAR has no such
27//!   primitive today.
28//! - `discard` is `handoff` plus scope rollback, which is proposal item 4 —
29//!   measured on 2026-08-09 and found constrained on macOS (`clonefile` is flat
30//!   in bytes but linear in file count, so restore is only viable for a bounded,
31//!   artifact-free working set).
32//!
33//! So the vocabulary here is the one the admission seam can actually honour:
34//! allow, deny, escalate-to-human. [`SupervisionDecision`] is
35//! `#[non_exhaustive]` so the Shepherd verbs can join it without breaking
36//! callers — but a verb that cannot be routed is worse than an absent one, and
37//! this crate does not ship promises it cannot keep.
38//!
39//! ## Two properties that make it safe to leave registered
40//!
41//! **Inert when nobody is subscribed.** `AdmissionGate`'s contract says
42//! implementations "must be cheap and side-effect-free: they run on the hot path
43//! of every admitted proposal". A network round trip is neither. So the gate
44//! checks its subscriber set first and returns `Allow` without allocating an
45//! intent when no supervisor matches. The cost is a read-lock on an empty map.
46//!
47//! **Fail-closed on timeout.** A supervisor that dies mid-decision must not
48//! become an open door. An unanswered intent is a `Reject`, consistent with the
49//! rest of admission, where an unresolved escalation blocks rather than passes.
50//!
51//! ## Why the payload is trimmed
52//!
53//! Shepherd's cost finding is that supervision is affordable *because it is
54//! batched and trimmed* — ~150 bytes per tool call, keeping a supervisor prompt
55//! under 3K tokens with 10 agents in flight, not because the meta-model is
56//! cheap. An intent therefore carries action identity, type, tool, and
57//! reversibility, plus a **digest** of the parameters rather than the parameters
58//! themselves. A supervisor that needs the full text asks for it out of band;
59//! the default path stays small enough to put in a prompt.
60//!
61//! The batching half is [`SupervisionRegistry::pending`], surfaced as
62//! `supervision.pending`: every intent currently parked, so a supervisor can
63//! make one model call across all of them and then issue N decisions. Delivery
64//! stays one notification per intent — a proposal blocks on *its own* verdict,
65//! and coalescing deliveries would only add latency to the first intent in a
66//! tick without reducing the number of decisions owed.
67
68use std::collections::{HashMap, HashSet};
69use std::sync::Arc;
70
71use car_engine::admission::{AdmissionGate, GateContext, GateOutcome};
72use car_ir::{Action, ActionProposal};
73use car_server_types::host::EventSubscriber;
74use chrono::{DateTime, Utc};
75use serde::{Deserialize, Serialize};
76use serde_json::Value;
77use tokio::sync::{Mutex, Notify};
78
79/// How long an intent waits for a decision before failing closed.
80pub const DEFAULT_DECISION_TIMEOUT_MS: u64 = 30_000;
81
82/// Cap on how many intents may be parked at once. A supervisor that stops
83/// answering must not let the pending table grow without bound; past this,
84/// new intents are rejected outright rather than queued behind a dead
85/// supervisor.
86pub const MAX_PENDING_INTENTS: usize = 256;
87
88/// One action, trimmed for a supervisor prompt. See the module docs on why the
89/// parameters are digested rather than carried.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct IntentAction {
92    pub id: String,
93    /// `car_ir::ActionType`'s serde label (`tool_call`, `state_write`, …).
94    pub action_type: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub tool: Option<String>,
97    /// `reversible` / `compensable` / `irreversible` — the axis that answers
98    /// "can this be undone?", which is what a supervisor deciding whether to
99    /// intervene actually needs. Distinct from *who may authorize it*.
100    pub reversibility: String,
101    /// Parameter key names only, sorted. Names are cheap and often decisive
102    /// (`command` vs `path`); values can be megabytes.
103    #[serde(default)]
104    pub parameter_keys: Vec<String>,
105    /// Stable digest of the full parameter map, so a supervisor can correlate
106    /// or cache a decision without the daemon shipping the values.
107    pub parameters_digest: String,
108}
109
110impl IntentAction {
111    fn from_action(action: &Action) -> Self {
112        let mut parameter_keys: Vec<String> = action.parameters.keys().cloned().collect();
113        parameter_keys.sort();
114        Self {
115            id: action.id.clone(),
116            action_type: action_type_label(action),
117            tool: action.tool.clone(),
118            reversibility: reversibility_label(action),
119            parameters_digest: digest_parameters(&action.parameters),
120            parameter_keys,
121        }
122    }
123}
124
125/// Serde label for the action type, taken from its own serialization so this
126/// never drifts from the wire form the rest of the system uses.
127fn action_type_label(action: &Action) -> String {
128    match serde_json::to_value(&action.action_type) {
129        Ok(Value::String(s)) => s,
130        Ok(other) => other.to_string(),
131        Err(_) => "unknown".to_string(),
132    }
133}
134
135fn reversibility_label(action: &Action) -> String {
136    match serde_json::to_value(action.reversibility) {
137        Ok(Value::String(s)) => s,
138        _ => "irreversible".to_string(),
139    }
140}
141
142/// A stable, order-independent digest of a parameter map.
143///
144/// Deliberately not a cryptographic commitment — it exists so a supervisor can
145/// say "I have seen this exact call before", not to prove anything to anyone.
146/// Order independence matters because `HashMap` iteration order is not stable
147/// across runs, and a digest that changed run to run would make every cached
148/// decision miss.
149fn digest_parameters(parameters: &HashMap<String, Value>) -> String {
150    let mut entries: Vec<(&String, String)> = parameters
151        .iter()
152        .map(|(k, v)| (k, serde_json::to_string(v).unwrap_or_default()))
153        .collect();
154    entries.sort_by(|a, b| a.0.cmp(b.0));
155
156    // FNV-1a, 64-bit. Small, dependency-free, and adequate for a correlation
157    // key. If this ever needs to resist an adversary, it is the wrong function.
158    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
159    for (key, value) in entries {
160        for byte in key.as_bytes().iter().chain(b"=").chain(value.as_bytes()) {
161            hash ^= *byte as u64;
162            hash = hash.wrapping_mul(0x1000_0000_01b3);
163        }
164    }
165    format!("{hash:016x}")
166}
167
168/// A proposal awaiting a supervisor's verdict.
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct SupervisionIntent {
171    /// Identity a decision is issued against.
172    pub id: String,
173    pub proposal_id: String,
174    /// Where the proposal came from — `ActionProposal::source`.
175    pub source: String,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub session_id: Option<String>,
178    /// Caller/tenant identity, when the runtime is scoped.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub scope: Option<String>,
181    pub actions: Vec<IntentAction>,
182    /// The least-reversible contract across the batch — the batch's own
183    /// rollback contract, so a supervisor can triage on one field.
184    pub reversibility: String,
185    pub created_at: DateTime<Utc>,
186}
187
188/// What a supervisor decides about an intent.
189///
190/// `#[non_exhaustive]`: Shepherd's `inject`/`handoff`/`discard` may join this
191/// once the seams they need exist. See the module docs for why they are absent
192/// rather than stubbed.
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum SupervisionDecision {
197    /// Proceed. Other gates still apply — one supervisor's allow is not a
198    /// bypass of the rest of admission.
199    Allow,
200    /// Refuse. Nothing in the proposal runs.
201    Deny { reason: String },
202    /// Hand the question to the human approval ledger rather than answering it.
203    /// Lets a supervisor be conservative about what it is unsure of without
204    /// either blocking the work or waving it through.
205    Escalate { reason: String },
206}
207
208/// Which proposals a supervisor wants to see. An empty filter means all of
209/// them.
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
211pub struct SupervisionFilter {
212    /// Only intents whose proposal touches one of these tools.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub tools: Option<Vec<String>>,
215    /// Only intents from these sessions.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub sessions: Option<Vec<String>>,
218    /// Only intents at or below this rollback contract — e.g. `compensable`
219    /// also matches `irreversible`, because a supervisor asking to see the
220    /// risky ones means "this risky and worse".
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub min_reversibility: Option<String>,
223}
224
225impl SupervisionFilter {
226    fn matches(&self, intent: &SupervisionIntent) -> bool {
227        if let Some(sessions) = &self.sessions {
228            match &intent.session_id {
229                Some(id) if sessions.iter().any(|s| s == id) => {}
230                _ => return false,
231            }
232        }
233        if let Some(tools) = &self.tools {
234            let hit = intent
235                .actions
236                .iter()
237                .filter_map(|a| a.tool.as_ref())
238                .any(|t| tools.iter().any(|w| w == t));
239            if !hit {
240                return false;
241            }
242        }
243        if let Some(min) = &self.min_reversibility {
244            if severity(&intent.reversibility) < severity(min) {
245                return false;
246            }
247        }
248        true
249    }
250}
251
252/// Ordering on the rollback contract: reversible < compensable < irreversible.
253/// Matches `car_ir::Reversibility`'s own severity ordering. An unrecognised
254/// label sorts as the most severe, so a future variant is over-reported to
255/// supervisors rather than silently filtered out of view.
256fn severity(label: &str) -> u8 {
257    match label {
258        "reversible" => 0,
259        "compensable" => 1,
260        _ => 2,
261    }
262}
263
264struct Supervisor {
265    filter: SupervisionFilter,
266    channel: Arc<dyn EventSubscriber>,
267}
268
269struct PendingIntent {
270    intent: SupervisionIntent,
271    decision: Option<SupervisionDecision>,
272    notify: Arc<Notify>,
273}
274
275/// Subscribers, parked intents, and the decisions that release them.
276pub struct SupervisionRegistry {
277    supervisors: Mutex<HashMap<String, Supervisor>>,
278    pending: Mutex<HashMap<String, PendingIntent>>,
279    timeout: std::time::Duration,
280}
281
282impl Default for SupervisionRegistry {
283    fn default() -> Self {
284        Self::new(std::time::Duration::from_millis(
285            DEFAULT_DECISION_TIMEOUT_MS,
286        ))
287    }
288}
289
290impl SupervisionRegistry {
291    pub fn new(timeout: std::time::Duration) -> Self {
292        Self {
293            supervisors: Mutex::new(HashMap::new()),
294            pending: Mutex::new(HashMap::new()),
295            timeout,
296        }
297    }
298
299    pub fn timeout(&self) -> std::time::Duration {
300        self.timeout
301    }
302
303    /// Register (or re-register, replacing the filter) a supervisor.
304    pub async fn subscribe(
305        &self,
306        client_id: &str,
307        filter: SupervisionFilter,
308        channel: Arc<dyn EventSubscriber>,
309    ) {
310        self.supervisors
311            .lock()
312            .await
313            .insert(client_id.to_string(), Supervisor { filter, channel });
314    }
315
316    /// Drop a supervisor. Returns whether one was registered.
317    ///
318    /// Any intent it left parked keeps waiting for its timeout rather than
319    /// being released here: another supervisor may still answer, and releasing
320    /// on unsubscribe would let a supervisor turn a pending decision into an
321    /// allow by disconnecting.
322    pub async fn unsubscribe(&self, client_id: &str) -> bool {
323        self.supervisors.lock().await.remove(client_id).is_some()
324    }
325
326    pub async fn is_subscribed(&self, client_id: &str) -> bool {
327        self.supervisors.lock().await.contains_key(client_id)
328    }
329
330    pub async fn subscriber_count(&self) -> usize {
331        self.supervisors.lock().await.len()
332    }
333
334    /// Every currently-parked intent, newest last. The batching half of the
335    /// design — one model call can cover all of them.
336    pub async fn pending(&self) -> Vec<SupervisionIntent> {
337        let mut intents: Vec<SupervisionIntent> = self
338            .pending
339            .lock()
340            .await
341            .values()
342            .map(|p| p.intent.clone())
343            .collect();
344        intents.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
345        intents
346    }
347
348    /// Record a decision and wake whoever is parked on it.
349    ///
350    /// `Err` when the intent is unknown — already decided, already timed out,
351    /// or never existed. Deliberately an error rather than a silent no-op: a
352    /// supervisor that believes it denied something needs to hear that the
353    /// denial did not land.
354    pub async fn decide(
355        &self,
356        intent_id: &str,
357        decision: SupervisionDecision,
358    ) -> Result<(), String> {
359        let mut pending = self.pending.lock().await;
360        let entry = pending
361            .get_mut(intent_id)
362            .ok_or_else(|| format!("no pending supervision intent '{intent_id}'"))?;
363        if entry.decision.is_some() {
364            return Err(format!("intent '{intent_id}' was already decided"));
365        }
366        entry.decision = Some(decision);
367        entry.notify.notify_waiters();
368        Ok(())
369    }
370
371    /// Publish an intent to every matching supervisor and park until one
372    /// decides or the timeout fires.
373    ///
374    /// `None` means the timeout won — the caller fails closed.
375    async fn publish_and_wait(&self, intent: SupervisionIntent) -> Option<SupervisionDecision> {
376        let targets: Vec<Arc<dyn EventSubscriber>> = {
377            let supervisors = self.supervisors.lock().await;
378            supervisors
379                .values()
380                .filter(|s| s.filter.matches(&intent))
381                .map(|s| s.channel.clone())
382                .collect()
383        };
384        if targets.is_empty() {
385            return Some(SupervisionDecision::Allow);
386        }
387
388        let intent_id = intent.id.clone();
389        let notify = Arc::new(Notify::new());
390        {
391            let mut pending = self.pending.lock().await;
392            if pending.len() >= MAX_PENDING_INTENTS {
393                return None;
394            }
395            pending.insert(
396                intent_id.clone(),
397                PendingIntent {
398                    intent: intent.clone(),
399                    decision: None,
400                    notify: notify.clone(),
401                },
402            );
403        }
404
405        // Subscribe to the wakeup BEFORE sending, so a supervisor that answers
406        // synchronously inside its send cannot land the decision in the window
407        // between publish and park.
408        let waiter = notify.notified();
409        tokio::pin!(waiter);
410
411        if let Ok(frame) = serde_json::to_string(&serde_json::json!({
412            "jsonrpc": "2.0",
413            "method": "supervision.intent",
414            "params": intent,
415        })) {
416            for target in targets {
417                target.send_text(frame.clone()).await;
418            }
419        }
420
421        let outcome = tokio::time::timeout(self.timeout, waiter).await;
422
423        let mut pending = self.pending.lock().await;
424        let entry = pending.remove(&intent_id);
425        match (outcome, entry) {
426            // Take the decision whenever one is recorded, even if the timeout
427            // also fired: a verdict that arrived is a verdict, and discarding
428            // it on a race would turn a supervisor's `allow` into a block.
429            (
430                _,
431                Some(PendingIntent {
432                    decision: Some(d), ..
433                }),
434            ) => Some(d),
435            _ => None,
436        }
437    }
438}
439
440/// The `AdmissionGate` that consults out-of-process supervisors.
441pub struct SupervisionGate {
442    registry: Arc<SupervisionRegistry>,
443}
444
445impl SupervisionGate {
446    pub fn new(registry: Arc<SupervisionRegistry>) -> Self {
447        Self { registry }
448    }
449
450    fn intent_for(proposal: &ActionProposal, ctx: &GateContext<'_>) -> SupervisionIntent {
451        SupervisionIntent {
452            id: format!("intent-{}", uuid_like()),
453            proposal_id: proposal.id.clone(),
454            source: proposal.source.clone(),
455            session_id: ctx.session_id.map(|s| s.to_string()),
456            scope: ctx.scope.map(|s| format!("{s:?}")),
457            actions: proposal
458                .actions
459                .iter()
460                .map(IntentAction::from_action)
461                .collect(),
462            reversibility: match serde_json::to_value(proposal.rollback_contract()) {
463                Ok(Value::String(s)) => s,
464                _ => "irreversible".to_string(),
465            },
466            created_at: Utc::now(),
467        }
468    }
469}
470
471fn uuid_like() -> String {
472    use std::sync::atomic::{AtomicU64, Ordering};
473    static COUNTER: AtomicU64 = AtomicU64::new(0);
474    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
475    format!("{:x}-{:x}", Utc::now().timestamp_micros(), n)
476}
477
478#[async_trait::async_trait]
479impl AdmissionGate for SupervisionGate {
480    fn name(&self) -> &str {
481        "supervision"
482    }
483
484    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
485        // Inert when nobody is watching — see the module docs. This is the
486        // check that keeps the gate honest about `AdmissionGate`'s "must be
487        // cheap" contract on the hot path.
488        if self.registry.subscriber_count().await == 0 {
489            return GateOutcome::Allow;
490        }
491
492        let intent = Self::intent_for(proposal, ctx);
493        let all_actions: HashSet<String> = proposal.actions.iter().map(|a| a.id.clone()).collect();
494
495        match self.registry.publish_and_wait(intent).await {
496            Some(SupervisionDecision::Allow) => GateOutcome::Allow,
497            Some(SupervisionDecision::Deny { reason }) => GateOutcome::Reject {
498                blocked: all_actions,
499                reason: format!("supervisor denied: {reason}"),
500            },
501            Some(SupervisionDecision::Escalate { reason }) => GateOutcome::NeedsApproval {
502                fingerprint: format!("supervision:{}", proposal.id),
503                actions: all_actions,
504                reason: format!("supervisor escalated: {reason}"),
505            },
506            // Fail closed. A supervisor that died mid-decision must not become
507            // an open door.
508            None => GateOutcome::Reject {
509                blocked: all_actions,
510                reason: format!(
511                    "no supervisor decision within {}ms (fail-closed)",
512                    self.registry.timeout().as_millis()
513                ),
514            },
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use car_ir::{ActionType, Reversibility};
523    use std::sync::Mutex as StdMutex;
524
525    /// Records what was sent, and can auto-answer to exercise the race where a
526    /// decision lands during the publish loop.
527    struct Recorder {
528        frames: Arc<StdMutex<Vec<String>>>,
529        auto: Option<(Arc<SupervisionRegistry>, SupervisionDecision)>,
530    }
531
532    #[async_trait::async_trait]
533    impl EventSubscriber for Recorder {
534        async fn send_text(&self, json: String) {
535            self.frames.lock().unwrap().push(json.clone());
536            if let Some((registry, decision)) = &self.auto {
537                let parsed: Value = serde_json::from_str(&json).unwrap();
538                let id = parsed["params"]["id"].as_str().unwrap().to_string();
539                let _ = registry.decide(&id, decision.clone()).await;
540            }
541        }
542    }
543
544    fn recorder() -> (Arc<Recorder>, Arc<StdMutex<Vec<String>>>) {
545        let frames = Arc::new(StdMutex::new(Vec::new()));
546        (
547            Arc::new(Recorder {
548                frames: frames.clone(),
549                auto: None,
550            }),
551            frames,
552        )
553    }
554
555    fn proposal(tool: &str, reversibility: Reversibility) -> ActionProposal {
556        let mut action = Action::tool_call(tool);
557        action.reversibility = reversibility;
558        action = action.with_param("path", Value::from("/tmp/x"));
559        ActionProposal {
560            id: "prop-1".to_string(),
561            source: "test".to_string(),
562            actions: vec![action],
563            timestamp: Utc::now(),
564            context: HashMap::new(),
565        }
566    }
567
568    async fn check(gate: &SupervisionGate, p: &ActionProposal) -> GateOutcome {
569        let state = HashMap::new();
570        let versions = HashMap::new();
571        let ctx = GateContext {
572            session_id: Some("sess-1"),
573            scope: None,
574            state: &state,
575            versions: &versions,
576        };
577        gate.check(p, &ctx).await
578    }
579
580    #[tokio::test]
581    async fn a_gate_with_no_subscribers_is_inert() {
582        let registry = Arc::new(SupervisionRegistry::default());
583        let gate = SupervisionGate::new(registry.clone());
584        // Would block for the full timeout if it published; the test finishing
585        // is itself the assertion that it did not.
586        assert!(matches!(
587            check(&gate, &proposal("write_file", Reversibility::Irreversible)).await,
588            GateOutcome::Allow
589        ));
590        assert!(registry.pending().await.is_empty());
591    }
592
593    #[tokio::test]
594    async fn an_allow_decision_admits_the_proposal() {
595        let registry = Arc::new(SupervisionRegistry::default());
596        let sub = Arc::new(Recorder {
597            frames: Arc::new(StdMutex::new(Vec::new())),
598            auto: Some((registry.clone(), SupervisionDecision::Allow)),
599        });
600        registry
601            .subscribe("sup-1", SupervisionFilter::default(), sub)
602            .await;
603        let gate = SupervisionGate::new(registry);
604        assert!(matches!(
605            check(&gate, &proposal("write_file", Reversibility::Reversible)).await,
606            GateOutcome::Allow
607        ));
608    }
609
610    #[tokio::test]
611    async fn a_deny_blocks_every_action_in_the_proposal() {
612        let registry = Arc::new(SupervisionRegistry::default());
613        let sub = Arc::new(Recorder {
614            frames: Arc::new(StdMutex::new(Vec::new())),
615            auto: Some((
616                registry.clone(),
617                SupervisionDecision::Deny {
618                    reason: "not on a Friday".to_string(),
619                },
620            )),
621        });
622        registry
623            .subscribe("sup-1", SupervisionFilter::default(), sub)
624            .await;
625        let gate = SupervisionGate::new(registry);
626        match check(&gate, &proposal("deploy", Reversibility::Irreversible)).await {
627            GateOutcome::Reject { blocked, reason } => {
628                assert_eq!(blocked.len(), 1);
629                assert!(reason.contains("not on a Friday"), "{reason}");
630            }
631            other => panic!("expected Reject, got {other:?}"),
632        }
633    }
634
635    #[tokio::test]
636    async fn an_escalation_becomes_a_human_approval() {
637        let registry = Arc::new(SupervisionRegistry::default());
638        let sub = Arc::new(Recorder {
639            frames: Arc::new(StdMutex::new(Vec::new())),
640            auto: Some((
641                registry.clone(),
642                SupervisionDecision::Escalate {
643                    reason: "unsure".to_string(),
644                },
645            )),
646        });
647        registry
648            .subscribe("sup-1", SupervisionFilter::default(), sub)
649            .await;
650        let gate = SupervisionGate::new(registry);
651        match check(&gate, &proposal("deploy", Reversibility::Irreversible)).await {
652            GateOutcome::NeedsApproval { fingerprint, .. } => {
653                assert_eq!(fingerprint, "supervision:prop-1");
654            }
655            other => panic!("expected NeedsApproval, got {other:?}"),
656        }
657    }
658
659    #[tokio::test]
660    async fn a_silent_supervisor_fails_closed() {
661        let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
662            60,
663        )));
664        let (sub, frames) = recorder();
665        registry
666            .subscribe("sup-1", SupervisionFilter::default(), sub)
667            .await;
668        let gate = SupervisionGate::new(registry.clone());
669        match check(&gate, &proposal("rm", Reversibility::Irreversible)).await {
670            GateOutcome::Reject { reason, .. } => {
671                assert!(reason.contains("fail-closed"), "{reason}")
672            }
673            other => panic!("expected fail-closed Reject, got {other:?}"),
674        }
675        assert_eq!(
676            frames.lock().unwrap().len(),
677            1,
678            "intent should be published once"
679        );
680        // The parked entry must be reaped, or a dead supervisor leaks memory.
681        assert!(registry.pending().await.is_empty());
682    }
683
684    #[tokio::test]
685    async fn unsubscribing_does_not_release_a_parked_intent_as_allow() {
686        // Otherwise a supervisor could turn "deny" into "allow" by disconnecting.
687        let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
688            60,
689        )));
690        let (sub, _) = recorder();
691        registry
692            .subscribe("sup-1", SupervisionFilter::default(), sub)
693            .await;
694        let gate = SupervisionGate::new(registry.clone());
695        let reg = registry.clone();
696        tokio::spawn(async move {
697            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
698            reg.unsubscribe("sup-1").await;
699        });
700        assert!(matches!(
701            check(&gate, &proposal("rm", Reversibility::Irreversible)).await,
702            GateOutcome::Reject { .. }
703        ));
704    }
705
706    #[tokio::test]
707    async fn deciding_an_unknown_intent_is_an_error_not_a_silent_noop() {
708        let registry = SupervisionRegistry::default();
709        let err = registry
710            .decide("intent-nope", SupervisionDecision::Allow)
711            .await
712            .unwrap_err();
713        assert!(err.contains("no pending supervision intent"), "{err}");
714    }
715
716    #[tokio::test]
717    async fn an_intent_cannot_be_decided_twice() {
718        let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
719            200,
720        )));
721        let (sub, _) = recorder();
722        registry
723            .subscribe("sup-1", SupervisionFilter::default(), sub)
724            .await;
725        let gate = SupervisionGate::new(registry.clone());
726        let reg = registry.clone();
727        let handle =
728            tokio::spawn(
729                async move { check(&gate, &proposal("rm", Reversibility::Reversible)).await },
730            );
731        // Wait for the intent to park.
732        let id = loop {
733            if let Some(i) = reg.pending().await.first() {
734                break i.id.clone();
735            }
736            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
737        };
738        reg.decide(&id, SupervisionDecision::Allow).await.unwrap();
739        let second = reg.decide(&id, SupervisionDecision::Allow).await;
740        assert!(
741            second.is_err(),
742            "a decided intent must not accept a second verdict"
743        );
744        assert!(matches!(handle.await.unwrap(), GateOutcome::Allow));
745    }
746
747    #[tokio::test]
748    async fn a_filter_that_does_not_match_leaves_the_proposal_unsupervised() {
749        let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
750            60,
751        )));
752        let (sub, frames) = recorder();
753        registry
754            .subscribe(
755                "sup-1",
756                SupervisionFilter {
757                    tools: Some(vec!["deploy".to_string()]),
758                    ..Default::default()
759                },
760                sub,
761            )
762            .await;
763        let gate = SupervisionGate::new(registry);
764        // Subscriber count is non-zero, so the gate does build an intent — but
765        // no supervisor matches it, so it must admit rather than fail closed.
766        assert!(matches!(
767            check(&gate, &proposal("read_file", Reversibility::Reversible)).await,
768            GateOutcome::Allow
769        ));
770        assert!(frames.lock().unwrap().is_empty());
771    }
772
773    #[tokio::test]
774    async fn min_reversibility_matches_this_severity_and_worse() {
775        let registry = Arc::new(SupervisionRegistry::new(std::time::Duration::from_millis(
776            60,
777        )));
778        let (sub, frames) = recorder();
779        registry
780            .subscribe(
781                "sup-1",
782                SupervisionFilter {
783                    min_reversibility: Some("compensable".to_string()),
784                    ..Default::default()
785                },
786                sub,
787            )
788            .await;
789        let gate = SupervisionGate::new(registry);
790        // reversible is BELOW the floor — not shown.
791        let _ = check(&gate, &proposal("read", Reversibility::Reversible)).await;
792        assert!(frames.lock().unwrap().is_empty());
793        // irreversible is ABOVE it — shown.
794        let _ = check(&gate, &proposal("rm", Reversibility::Irreversible)).await;
795        assert_eq!(frames.lock().unwrap().len(), 1);
796    }
797
798    #[test]
799    fn the_parameter_digest_is_order_independent_and_value_sensitive() {
800        let mut a = HashMap::new();
801        a.insert("x".to_string(), Value::from(1));
802        a.insert("y".to_string(), Value::from("two"));
803        let mut b = HashMap::new();
804        b.insert("y".to_string(), Value::from("two"));
805        b.insert("x".to_string(), Value::from(1));
806        assert_eq!(digest_parameters(&a), digest_parameters(&b));
807
808        let mut c = HashMap::new();
809        c.insert("x".to_string(), Value::from(2));
810        c.insert("y".to_string(), Value::from("two"));
811        assert_ne!(digest_parameters(&a), digest_parameters(&c));
812    }
813
814    #[test]
815    fn an_intent_carries_key_names_but_never_parameter_values() {
816        let mut action = Action::tool_call("run");
817        action = action.with_param("command", Value::from("rm -rf /secret/path"));
818        let trimmed = IntentAction::from_action(&action);
819        let json = serde_json::to_string(&trimmed).unwrap();
820        assert!(json.contains("command"), "key names are useful and cheap");
821        assert!(
822            !json.contains("secret"),
823            "parameter VALUES must not ride along: {json}"
824        );
825    }
826
827    #[test]
828    fn an_unknown_reversibility_label_sorts_as_most_severe() {
829        // A future variant must be over-reported to supervisors, never
830        // silently filtered out of view.
831        assert_eq!(severity("something_new"), severity("irreversible"));
832    }
833
834    #[test]
835    fn the_decision_wire_form_is_tagged_and_snake_case() {
836        let json = serde_json::to_string(&SupervisionDecision::Deny {
837            reason: "no".to_string(),
838        })
839        .unwrap();
840        assert_eq!(json, r#"{"kind":"deny","reason":"no"}"#);
841        let parsed: SupervisionDecision = serde_json::from_str(r#"{"kind":"allow"}"#).unwrap();
842        assert_eq!(parsed, SupervisionDecision::Allow);
843    }
844
845    #[test]
846    fn action_type_and_reversibility_labels_come_from_serde_not_a_second_table() {
847        let mut action = Action::new(ActionType::StateWrite);
848        action.reversibility = Reversibility::Compensable;
849        let trimmed = IntentAction::from_action(&action);
850        assert_eq!(trimmed.action_type, "state_write");
851        assert_eq!(trimmed.reversibility, "compensable");
852    }
853}