Skip to main content

switchyard_libsy/
privacy_preflight.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional semantic privacy assessment through host-served judge calls.
5
6use std::collections::BTreeMap;
7use std::future::Future;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use switchyard_protocol::{
14    Category, ChoiceOption, DecisionKind, DecisionQuestion, DecisionRequest, DecisionResponse,
15    DecisionValue, FormatId, InstructionBlock, LlmRequest, LlmResponse, Message, ModelId,
16    OutputParams, PreservationMetadata, ProviderExtensions, ReasoningParams, Request, Role,
17    ToolChoice, ToolDefinition,
18};
19
20use crate::algorithms::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
21use crate::algorithms::util::llm_judge::{JudgeRuntimeConfig, SerdeDecoder, StructuredJudge};
22use crate::{Call, ClassifierResponseFormat, Driver, Result, RuntimeModels, Step};
23
24const MAX_CONTEXT_BYTES: usize = 64 * 1024;
25const QUESTION_ID: &str = "privacy";
26const PROBABILITY_SUM_TOLERANCE: f64 = 1e-6;
27const DEFAULT_PROMPT: &str = include_str!("prompts/privacy-classifier/prompt.md");
28
29/// A semantic assessment independent of lane selection and task retention.
30pub struct PrivacyAssessment {
31    reason_code: &'static str,
32    clear_score: Option<f64>,
33    has_sensitive_content: bool,
34}
35
36impl PrivacyAssessment {
37    /// Returns a bounded category or failure reason, without request or provider text.
38    pub const fn reason_code(&self) -> &'static str {
39        self.reason_code
40    }
41
42    /// Returns the model's clear-content score when the verdict is valid.
43    pub const fn clear_score(&self) -> Option<f64> {
44        self.clear_score
45    }
46
47    /// Reports an affirmative sensitive category; false also includes failures and is not clearance.
48    pub const fn has_sensitive_content(&self) -> bool {
49        self.has_sensitive_content
50    }
51
52    fn failure(reason_code: &'static str) -> Self {
53        Self {
54            reason_code,
55            clear_score: None,
56            has_sensitive_content: false,
57        }
58    }
59}
60
61/// Runs one optional privacy judge call through the host's existing [`Call`] handler.
62/// The caller applies thresholds, chooses permitted targets, and retains task restrictions.
63/// Routing and externally supplied restrictions do not require this detector.
64pub struct PrivacyPreflight {
65    target: ModelId,
66    models: Arc<RuntimeModels>,
67    backend: Backend,
68}
69
70enum Backend {
71    Decision(DecisionQuestion),
72    Llm(StructuredJudge<(), SerdeDecoder<LlmVerdict>>),
73}
74
75impl PrivacyPreflight {
76    /// Builds a typed decision question, optionally replacing its standing instructions.
77    pub fn decision(target: ModelId, instructions: Option<&Value>) -> Self {
78        let question = DecisionQuestion {
79            instructions: instructions
80                .cloned()
81                .unwrap_or_else(|| Value::String(DEFAULT_PROMPT.trim().into())),
82            kind: DecisionKind::Choice {
83                options: PrivacyVerdict::ALL
84                    .into_iter()
85                    .map(|verdict| ChoiceOption {
86                        id: verdict.as_str().into(),
87                        description: Some(Value::String(verdict.description().into())),
88                    })
89                    .collect(),
90            },
91        };
92        Self::new(target, Backend::Decision(question))
93    }
94
95    /// Builds a JSON judge using the shared classifier contract and typed decoder.
96    /// Returns an error if the prompt or response contract is invalid.
97    pub fn llm(
98        target: ModelId,
99        prompt: Option<&str>,
100        format: ClassifierResponseFormat,
101    ) -> Result<Self> {
102        if prompt.is_some_and(|prompt| prompt.trim().is_empty()) {
103            return Err(crate::LibsyError::AlgorithmError {
104                message: "privacy classifier prompt must not be empty".into(),
105            });
106        }
107        let prompt = prompt.map(str::to_owned).unwrap_or_else(|| {
108            format!(
109                "{}\n\n{}",
110                DEFAULT_PROMPT.trim(),
111                include_str!("prompts/privacy-classifier/llm.md").trim()
112            )
113        });
114        let schema = verdict_schema();
115        // Both modes describe the schema, including endpoints that do not enforce it.
116        let prompt = match format {
117            ClassifierResponseFormat::JsonSchema => format!(
118                "{prompt}\n\nReturn exactly one JSON object matching this JSON Schema:\n{schema:#}"
119            ),
120            ClassifierResponseFormat::JsonObject => prompt,
121        };
122        let contract = ClassifierContract::from_config(
123            &ClassifierContractConfig::default()
124                .with_prompt(prompt)
125                .with_response_format_type(format),
126            DEFAULT_PROMPT,
127            &json!({
128                "type": "json_schema",
129                "json_schema": {
130                    "name": "switchyard_privacy_verdict",
131                    "strict": true,
132                    "schema": schema,
133                }
134            })
135            .to_string(),
136        )?;
137        let judge = StructuredJudge::new(
138            (),
139            contract,
140            SerdeDecoder::new(),
141            JudgeRuntimeConfig::new(512)?,
142        );
143        Ok(Self::new(target, Backend::Llm(judge)))
144    }
145
146    fn new(target: ModelId, backend: Backend) -> Self {
147        let models = RuntimeModels::new([(Category::Judge, vec![target.clone()])].into())
148            .with_target_restriction();
149        Self {
150            target,
151            models: Arc::new(models),
152            backend,
153        }
154    }
155
156    /// Assesses complete normalized content with one host-served call.
157    /// Failures return a bounded reason with no clear score. Dropping this future
158    /// cancels assessment and host work; no background task is spawned.
159    pub async fn assess<F, Fut>(&self, request: &Request, serve: F) -> PrivacyAssessment
160    where
161        F: FnOnce(Call) -> Fut + Send,
162        Fut: Future<Output = Result<()>> + Send,
163    {
164        let context = match classifier_context(&request.llm_request) {
165            Ok(context) => context,
166            Err(()) => return PrivacyAssessment::failure("input_unavailable"),
167        };
168        let (driver, mut steps) = Driver::new("privacy_classifier", Arc::clone(&self.models));
169        // Each assessment publishes one call; poll it with the verdict to preserve cancellation.
170        let handled = async {
171            let call = match steps.recv().await {
172                Some(Ok(Step::CallModel(call))) => Call::Model(call),
173                Some(Ok(Step::CallDecision(call))) => Call::Decision(call),
174                _ => return Err("classifier_failed"),
175            };
176            serve(call).await.map_err(|_| "classifier_failed")
177        };
178        tokio::try_join!(self.verdict(context, &driver), handled)
179            .map(|(assessment, ())| assessment)
180            .unwrap_or_else(PrivacyAssessment::failure)
181    }
182
183    async fn verdict(
184        &self,
185        context: BoundedJson,
186        driver: &Driver,
187    ) -> std::result::Result<PrivacyAssessment, &'static str> {
188        let (selected, clear_score) = match &self.backend {
189            Backend::Decision(question) => {
190                let request = DecisionRequest {
191                    model: None,
192                    context: context.into_value().map_err(|()| "input_unavailable")?,
193                    questions: BTreeMap::from([(QUESTION_ID.into(), question.clone())]),
194                };
195                let response = driver
196                    .call_decision(request, self.target.clone())
197                    .await
198                    .map_err(|_| "classifier_failed")?;
199                decision_verdict(&response).ok_or("invalid_verdict")?
200            }
201            Backend::Llm(judge) => {
202                let mut request = judge.build_request_with_messages(vec![Message::text(
203                    Role::User,
204                    context.into_string().map_err(|()| "input_unavailable")?,
205                )]);
206                request
207                    .llm_request
208                    .extensions
209                    .fields
210                    .insert("store".into(), Value::Bool(false));
211                let response = driver
212                    .call_model_with_error_recovery(request, vec![self.target.clone()], true)
213                    .await
214                    .map_err(|_| "classifier_failed")?;
215                let LlmResponse::Agg(response) = response.llm_response else {
216                    return Err("invalid_verdict");
217                };
218                let verdict = judge.decode(&response).map_err(|_| "invalid_verdict")?;
219                (verdict.reason_code, verdict.clear_score)
220            }
221        };
222        if !clear_score.is_finite() || !(0.0..=1.0).contains(&clear_score) {
223            return Err("invalid_verdict");
224        }
225        Ok(PrivacyAssessment {
226            reason_code: selected.as_str(),
227            clear_score: Some(clear_score),
228            has_sensitive_content: matches!(
229                selected,
230                PrivacyVerdict::PersonalData
231                    | PrivacyVerdict::Credentials
232                    | PrivacyVerdict::ConfidentialData
233                    | PrivacyVerdict::RegulatedData
234            ),
235        })
236    }
237}
238
239#[derive(Clone, Copy, Deserialize)]
240#[serde(rename_all = "snake_case")]
241enum PrivacyVerdict {
242    NoSensitiveContent,
243    PersonalData,
244    Credentials,
245    ConfidentialData,
246    RegulatedData,
247    Uncertain,
248}
249
250impl PrivacyVerdict {
251    const ALL: [Self; 6] = [
252        Self::NoSensitiveContent,
253        Self::PersonalData,
254        Self::Credentials,
255        Self::ConfidentialData,
256        Self::RegulatedData,
257        Self::Uncertain,
258    ];
259
260    const fn as_str(self) -> &'static str {
261        match self {
262            Self::NoSensitiveContent => "no_sensitive_content",
263            Self::PersonalData => "personal_data",
264            Self::Credentials => "credentials",
265            Self::ConfidentialData => "confidential_data",
266            Self::RegulatedData => "regulated_data",
267            Self::Uncertain => "uncertain",
268        }
269    }
270
271    const fn description(self) -> &'static str {
272        match self {
273            Self::NoSensitiveContent => "No personal, secret, confidential, or regulated content.",
274            Self::PersonalData => "Personal or identifying data is present.",
275            Self::Credentials => "Credentials, secrets, or authentication material are present.",
276            Self::ConfidentialData => "Confidential organizational data is present.",
277            Self::RegulatedData => "Regulated data is present.",
278            Self::Uncertain => "The privacy boundary cannot be determined confidently.",
279        }
280    }
281}
282
283#[derive(Deserialize)]
284#[serde(deny_unknown_fields)]
285struct LlmVerdict {
286    clear_score: f64,
287    reason_code: PrivacyVerdict,
288}
289
290fn verdict_schema() -> Value {
291    json!({
292        "type": "object", "additionalProperties": false,
293        "properties": {
294            "clear_score": {"type": "number", "minimum": 0, "maximum": 1},
295            "reason_code": {"type": "string", "enum": PrivacyVerdict::ALL.map(PrivacyVerdict::as_str)}
296        },
297        "required": ["clear_score", "reason_code"]
298    })
299}
300
301fn decision_verdict(response: &DecisionResponse) -> Option<(PrivacyVerdict, f64)> {
302    let DecisionValue::Choice {
303        selected,
304        probabilities: Some(probabilities),
305    } = &response.answers.get(QUESTION_ID)?.value
306    else {
307        return None;
308    };
309    let selected = PrivacyVerdict::deserialize(serde::de::value::StrDeserializer::<
310        serde::de::value::Error,
311    >::new(selected))
312    .ok()?;
313    let valid_distribution = probabilities.len() == PrivacyVerdict::ALL.len()
314        && PrivacyVerdict::ALL.iter().all(|verdict| {
315            probabilities
316                .get(verdict.as_str())
317                .is_some_and(|p| p.0.is_finite() && (0.0..=1.0).contains(&p.0))
318        })
319        && (probabilities.values().map(|p| p.0).sum::<f64>() - 1.0).abs()
320            <= PROBABILITY_SUM_TOLERANCE;
321    if !valid_distribution {
322        return None;
323    }
324    Some((selected, probabilities.get("no_sensitive_content")?.0))
325}
326
327// Borrow content-bearing fields; leave transport identity and cached responses out of assessment.
328#[derive(Serialize)]
329struct PrivacyContext<'a> {
330    instructions: &'a [InstructionBlock],
331    messages: &'a [Message],
332    tools: &'a [ToolDefinition],
333    tool_choice: Option<&'a ToolChoice>,
334    response_format: Option<&'a Value>,
335    reasoning_effort: Option<&'a str>,
336    reasoning_raw: Option<&'a Value>,
337    extensions: &'a Map<String, Value>,
338    preserved_requests: &'a BTreeMap<FormatId, Value>,
339}
340
341fn classifier_context(request: &LlmRequest) -> std::result::Result<BoundedJson, ()> {
342    // Exhaustive destructuring makes new protocol fields require a coverage decision.
343    let LlmRequest {
344        model: _,
345        instructions,
346        messages,
347        tools,
348        tool_choice,
349        sampling: _,
350        output,
351        reasoning,
352        stream: _,
353        extensions,
354        preservation,
355    } = request;
356    let OutputParams {
357        max_output_tokens: _,
358        response_format,
359        is_schema_enforced: _,
360    } = output;
361    let ReasoningParams { effort, raw } = reasoning;
362    let ProviderExtensions { fields: extensions } = extensions;
363    let PreservationMetadata {
364        requests: preserved_requests,
365        responses: _,
366    } = preservation;
367    let context = PrivacyContext {
368        instructions,
369        messages,
370        tools,
371        tool_choice: tool_choice.as_ref(),
372        response_format: response_format.as_ref(),
373        reasoning_effort: effort.as_deref(),
374        reasoning_raw: raw.as_ref(),
375        extensions,
376        preserved_requests,
377    };
378    let mut json = BoundedJson::default();
379    serde_json::to_writer(&mut json, &context).map_err(|_| ())?;
380    Ok(json)
381}
382
383#[derive(Default)]
384struct BoundedJson(Vec<u8>);
385
386impl BoundedJson {
387    fn into_value(self) -> std::result::Result<Value, ()> {
388        serde_json::from_slice(&self.0).map_err(|_| ())
389    }
390
391    fn into_string(self) -> std::result::Result<String, ()> {
392        String::from_utf8(self.0).map_err(|_| ())
393    }
394}
395
396impl Write for BoundedJson {
397    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
398        if bytes.len() > MAX_CONTEXT_BYTES.saturating_sub(self.0.len()) {
399            return Err(io::Error::other("privacy classifier context exceeds limit"));
400        }
401        self.0.extend_from_slice(bytes);
402        Ok(bytes.len())
403    }
404
405    fn flush(&mut self) -> io::Result<()> {
406        Ok(())
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use std::sync::atomic::{AtomicBool, Ordering};
413
414    use switchyard_protocol::{
415        DecisionAnswer, LlmClientError, Metadata, Probability, Response, Usage, text_response,
416    };
417    use tokio::sync::Notify;
418
419    use super::*;
420    use crate::LibsyError;
421
422    fn preflights() -> [PrivacyPreflight; 3] {
423        [
424            PrivacyPreflight::decision("private/classifier".into(), None),
425            PrivacyPreflight::llm(
426                "private/classifier".into(),
427                None,
428                ClassifierResponseFormat::JsonSchema,
429            )
430            .expect("schema judge"),
431            PrivacyPreflight::llm(
432                "private/classifier".into(),
433                None,
434                ClassifierResponseFormat::JsonObject,
435            )
436            .expect("object judge"),
437        ]
438    }
439
440    fn reply(call: Call, reason: &str, score: f64) -> Result<()> {
441        let failed = || {
442            LibsyError::client_call(
443                "private/classifier",
444                LlmClientError::Configuration {
445                    message: "private provider diagnostic".into(),
446                },
447            )
448        };
449        match call {
450            Call::Model(call) => {
451                assert!(call.recover_errors);
452                if reason == "failed" {
453                    return call.respond(Err(failed()));
454                }
455                let content = if reason == "malformed" {
456                    "not JSON".into()
457                } else {
458                    json!({"reason_code": reason, "clear_score": score}).to_string()
459                };
460                call.respond(Ok(Response {
461                    llm_response: LlmResponse::Agg(text_response(None, content)),
462                    metadata: None,
463                    upstream_headers: Default::default(),
464                }))
465            }
466            Call::Decision(call) => {
467                if reason == "failed" {
468                    return call.respond(Err(failed()));
469                }
470                call.respond(Ok(decision_response(reason, score)))
471            }
472        }
473    }
474
475    fn decision_response(reason: &str, score: f64) -> DecisionResponse {
476        let remaining = if reason == "no_sensitive_content" {
477            "uncertain"
478        } else {
479            reason
480        };
481        let probabilities = PrivacyVerdict::ALL
482            .map(|verdict| {
483                let probability = match verdict.as_str() {
484                    "no_sensitive_content" => score,
485                    value if value == remaining => 1.0 - score,
486                    _ => 0.0,
487                };
488                (verdict.as_str().into(), Probability(probability))
489            })
490            .into();
491        DecisionResponse {
492            id: None,
493            model: None,
494            answers: BTreeMap::from([(
495                QUESTION_ID.into(),
496                DecisionAnswer {
497                    value: DecisionValue::Choice {
498                        selected: reason.into(),
499                        probabilities: Some(probabilities),
500                    },
501                    provider_confidence: None,
502                },
503            )]),
504            usage: Usage::default(),
505        }
506    }
507
508    // Invalid verdicts and recovered client errors never manufacture clearance or task evidence.
509    #[tokio::test]
510    async fn returns_only_valid_evidence_across_backends() {
511        for preflight in preflights() {
512            for (reason, score, expected, sensitive) in [
513                ("no_sensitive_content", 0.95, "no_sensitive_content", false),
514                ("personal_data", 0.1, "personal_data", true),
515                ("uncertain", 0.5, "uncertain", false),
516                ("malformed", 0.0, "invalid_verdict", false),
517                ("no_sensitive_content", 1.1, "invalid_verdict", false),
518                ("failed", 0.0, "classifier_failed", false),
519            ] {
520                let assessment = preflight
521                    .assess(&Request::default(), |call| async move {
522                        reply(call, reason, score)
523                    })
524                    .await;
525                assert_eq!(assessment.reason_code(), expected);
526                assert_eq!(assessment.has_sensitive_content(), sensitive);
527                assert_eq!(
528                    assessment.clear_score(),
529                    (!matches!(expected, "invalid_verdict" | "classifier_failed")).then_some(score)
530                );
531            }
532            let assessment = preflight
533                .assess(&Request::default(), |call| async move {
534                    reply(call, "no_sensitive_content", 0.95)?;
535                    Err(LibsyError::NoTargets)
536                })
537                .await;
538            assert_eq!(assessment.reason_code(), "classifier_failed");
539        }
540        for invalid in ["missing", "incomplete", "unnormalized"] {
541            let mut response = decision_response("no_sensitive_content", 0.95);
542            let DecisionValue::Choice { probabilities, .. } = &mut response
543                .answers
544                .get_mut(QUESTION_ID)
545                .expect("privacy answer")
546                .value
547            else {
548                panic!("expected choice");
549            };
550            match invalid {
551                "missing" => *probabilities = None,
552                "incomplete" => {
553                    probabilities
554                        .as_mut()
555                        .expect("distribution")
556                        .remove("uncertain");
557                }
558                _ => {
559                    probabilities
560                        .as_mut()
561                        .expect("distribution")
562                        .insert("personal_data".into(), Probability(0.2));
563                }
564            }
565            let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
566                .assess(&Request::default(), |call| async move {
567                    let Call::Decision(call) = call else {
568                        panic!("expected decision call");
569                    };
570                    call.respond(Ok(response))
571                })
572                .await;
573            assert_eq!(assessment.reason_code(), "invalid_verdict");
574            assert_eq!(assessment.clear_score(), None);
575            assert!(!assessment.has_sensitive_content());
576        }
577        let preflight = PrivacyPreflight::llm(
578            "private/classifier".into(),
579            None,
580            ClassifierResponseFormat::JsonSchema,
581        )
582        .expect("judge");
583        let assessment = preflight
584            .assess(&Request::default(), |call| async move {
585                let Call::Model(call) = call else {
586                    panic!("expected LLM call");
587                };
588                call.respond(Ok(Response {
589                    llm_response: LlmResponse::Stream(Box::pin(futures::stream::empty())),
590                    metadata: None,
591                    upstream_headers: Default::default(),
592                }))
593            })
594            .await;
595        assert_eq!(assessment.reason_code(), "invalid_verdict");
596    }
597
598    // Scan content-bearing fields without forwarding the original transport identity or auth.
599    #[tokio::test]
600    async fn bounds_full_context_and_preserves_judge_contracts() {
601        let mut request = Request {
602            llm_request: LlmRequest {
603                model: Some("public/answer".into()),
604                instructions: vec![InstructionBlock {
605                    role: Role::System,
606                    content: Message::text(Role::System, "system content").content,
607                }],
608                messages: vec![Message::text(Role::User, "user content")],
609                tools: vec![ToolDefinition {
610                    name: "private_tool".into(),
611                    description: Some("confidential tool content".into()),
612                    parameters: json!({"type": "object"}),
613                    strict: Some(true),
614                }],
615                ..Default::default()
616            },
617            raw_request: Some(json!({"host_only": "original body"})),
618            metadata: Some(Metadata {
619                task_id: Some("host-only-task".into()),
620                ..Default::default()
621            }),
622        };
623        request
624            .llm_request
625            .extensions
626            .fields
627            .insert("provider_content".into(), json!("private"));
628        request
629            .llm_request
630            .preservation
631            .requests
632            .insert("custom".into(), json!({"private": "preserved"}));
633        for preflight in preflights() {
634            let assessment = preflight
635                .assess(&request, |call| async move {
636                    let context = match &call {
637                        Call::Decision(call) => {
638                            assert_eq!(call.model.as_str(), "private/classifier");
639                            assert_eq!(call.request.questions.len(), 1);
640                            call.request.context.clone()
641                        }
642                        Call::Model(call) => {
643                            assert!(call.request.raw_request.is_none());
644                            assert!(call.request.metadata.is_none());
645                            assert_eq!(call.request.llm_request.output.max_output_tokens, Some(512));
646                            assert_eq!(call.request.llm_request.extensions.fields["store"], false);
647                            assert_eq!(call.request.model_id(), Some("private/classifier".into()));
648                            let prompt = Message {
649                                role: Role::System,
650                                content: call.request.llm_request.instructions[0].content.clone(),
651                            }.text_content("").expect("prompt");
652                            let expected = format!(
653                                "{}\n\n{}\n\nReturn exactly one JSON object matching this JSON Schema:\n{:#}",
654                                DEFAULT_PROMPT.trim(),
655                                include_str!("prompts/privacy-classifier/llm.md").trim(),
656                                verdict_schema(),
657                            );
658                            assert_eq!(prompt, expected);
659                            let format = call.request.llm_request.output.response_format.as_ref().expect("format");
660                            if format["type"] == "json_schema" {
661                                assert_eq!(format["json_schema"]["name"], "switchyard_privacy_verdict");
662                                assert_eq!(format["json_schema"]["strict"], true);
663                            } else {
664                                assert_eq!(format, &json!({"type": "json_object"}));
665                            }
666                            serde_json::from_str(&call.request.llm_request.messages[0].text_content("").expect("context")).expect("JSON")
667                        }
668                    };
669                    assert_eq!(context["tools"][0]["name"], "private_tool");
670                    assert_eq!(context["extensions"]["provider_content"], "private");
671                    assert_eq!(context["preserved_requests"]["custom"]["private"], "preserved");
672                    let serialized = context.to_string();
673                    assert!(serialized.contains("system content"));
674                    assert!(serialized.contains("user content"));
675                    assert!(!serialized.contains("host-only-task"));
676                    assert!(!serialized.contains("host_only"));
677                    reply(call, "no_sensitive_content", 0.95)
678                })
679                .await;
680            assert_eq!(assessment.reason_code(), "no_sensitive_content");
681        }
682        request.llm_request.messages =
683            vec![Message::text(Role::User, "x".repeat(MAX_CONTEXT_BYTES))];
684        let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
685            .assess(&request, |_| async {
686                panic!("oversized input must not leave the host")
687            })
688            .await;
689        assert_eq!(assessment.reason_code(), "input_unavailable");
690        assert!(assessment.clear_score().is_none());
691    }
692
693    // Dropping assessment must cancel the host future, not leave a classifier task running.
694    #[tokio::test]
695    async fn cancellation_drops_pending_host_work() {
696        struct OnDrop(Arc<AtomicBool>);
697        impl Drop for OnDrop {
698            fn drop(&mut self) {
699                self.0.store(true, Ordering::SeqCst);
700            }
701        }
702        let dropped = Arc::new(AtomicBool::new(false));
703        let started = Arc::new(Notify::new());
704        let host_started = Arc::clone(&started);
705        let host_dropped = Arc::clone(&dropped);
706        let task = tokio::spawn(async move {
707            PrivacyPreflight::decision("private/classifier".into(), None)
708                .assess(&Request::default(), |call| async move {
709                    let _call = call;
710                    let _guard = OnDrop(host_dropped);
711                    host_started.notify_one();
712                    futures::future::pending().await
713                })
714                .await
715        });
716        started.notified().await;
717        task.abort();
718        let Err(error) = task.await else {
719            panic!("assessment was not cancelled");
720        };
721        assert!(error.is_cancelled());
722        assert!(dropped.load(Ordering::SeqCst));
723    }
724}