Skip to main content

gaze/
pipeline.rs

1use std::collections::BTreeMap;
2use std::ops::Range;
3use std::sync::Arc;
4
5#[cfg(feature = "bundled-recognizers")]
6use gaze_recognizers::{
7    LocaleAwareModelRegistry, ModelError, ModelHints, ModelInput, ModelSpan, ModelStage,
8};
9use gaze_types::{
10    AmbiguityReason, AmbiguityRecord, CollisionMembership, EmittedTokenSpan, FallbackReason,
11    LeakKind, LeakReport, LeakReportTelemetry, LeakSuspect, Manifest, RedactionLogError,
12    RedactionLogger, RestoreDecision, RestorePolicy, RestoreTelemetry, RestoredText, SafetyNet,
13    SafetyNetContext, SafetyNetError, RESTORE_PHASE_MANIFEST_BYPASS_SCAN,
14    RESTORE_PHASE_MANIFEST_LOOKUP, RESTORE_PHASE_UNKNOWN_TOKEN_SCAN,
15};
16use thiserror::Error;
17
18use crate::detector::{Detection, Detector, PiiClass};
19use crate::normalize::normalize;
20use crate::policy::PolicyError;
21use crate::redaction_log::{ConflictTier, DocumentKind, RedactionEntry};
22use crate::registry::{Candidate, DetectContext, Recognizer, RecognizerRegistry};
23use crate::rule::{Action, Rule, RuleContext};
24use crate::rulepack::RulepackError;
25use crate::session::{RestoreEvent, Session};
26use crate::types::{CleanDocument, RawDocument, Value};
27use crate::DictionaryBundle;
28
29pub type Result<T> = std::result::Result<T, Error>;
30
31#[derive(Debug, Error)]
32#[non_exhaustive]
33pub enum Error {
34    #[error("invalid regex: {0}")]
35    InvalidRegex(#[source] regex::Error),
36    #[error("unknown token: [REDACTED]")]
37    UnknownToken {
38        class: PiiClass,
39        ordinal: u32,
40        raw: String,
41    },
42    #[error("ephemeral sessions cannot be exported")]
43    ExportForbidden,
44    #[error("document extension integrity fields cannot be empty")]
45    EmptyDocumentIntegrity,
46    #[error("invalid snapshot version: {0}")]
47    InvalidSnapshotVersion(u8),
48    #[error("snapshot signature verification failed")]
49    InvalidSnapshotSignature,
50    #[error("snapshot expired: issued_at={issued_at}, ttl_secs={ttl_secs}")]
51    BlobExpired { issued_at: u64, ttl_secs: u64 },
52    #[error("snapshot decode failed: {0}")]
53    SnapshotDecode(#[source] serde_json::Error),
54    #[error("invalid snapshot payload")]
55    InvalidSnapshotPayload,
56    #[error("sqlite error: {0}")]
57    Sqlite(String),
58    #[error("policy error: {0}")]
59    Policy(#[from] PolicyError),
60    #[error("rulepack error: {0}")]
61    Rulepack(#[from] RulepackError),
62    #[error("safety net error: {0}")]
63    SafetyNet(#[from] SafetyNetError),
64    #[error("recognizer detection failed: {0}")]
65    RecognizerDetect(#[from] gaze_types::DetectError),
66    #[error("redaction log error: {0}")]
67    RedactionLog(#[from] RedactionLogError),
68    #[error("safety net fallback failed closed: {0:?}")]
69    SafetyNetFallback(FallbackReason),
70    #[error("safety net span invalid: start={start}, end={end}, text_len={text_len}")]
71    SafetyNetSpanInvalid {
72        start: usize,
73        end: usize,
74        text_len: usize,
75    },
76    #[error("capitals heuristic gate is unsupported for locale {locale}")]
77    UnsupportedCapitalHeuristicLocale { locale: String },
78    #[error("unsupported raw document variant")]
79    UnsupportedRawDocumentVariant,
80    #[error("unsupported structured value variant")]
81    UnsupportedValueVariant,
82    #[error("unsupported policy action variant")]
83    UnsupportedActionVariant,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum SafetyNetMode {
89    Strict,
90    Tolerant,
91    Redact,
92    Resolve,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[non_exhaustive]
97pub enum SafetyNetFallback {
98    Strict,
99    Tolerant,
100    Redact,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct SafetyNetPolicy {
106    pub mode: SafetyNetMode,
107    pub fallback: SafetyNetFallback,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct PipelineOptimizationConfig {
113    pub skip_class_gating: bool,
114    pub capitals_heuristic_gate: bool,
115    pub prefix_cache: bool,
116    pub length_bucketing: bool,
117}
118
119impl PipelineOptimizationConfig {
120    pub fn new() -> Self {
121        Self::default()
122    }
123    pub fn with_skip_class_gating(mut self, enabled: bool) -> Self {
124        self.skip_class_gating = enabled;
125        self
126    }
127    pub fn with_capitals_heuristic_gate(mut self, enabled: bool) -> Self {
128        self.capitals_heuristic_gate = enabled;
129        self
130    }
131    pub fn with_prefix_cache(mut self, enabled: bool) -> Self {
132        self.prefix_cache = enabled;
133        self
134    }
135    pub fn with_length_bucketing(mut self, enabled: bool) -> Self {
136        self.length_bucketing = enabled;
137        self
138    }
139}
140
141impl Default for SafetyNetPolicy {
142    fn default() -> Self {
143        Self {
144            mode: SafetyNetMode::Resolve,
145            fallback: SafetyNetFallback::Redact,
146        }
147    }
148}
149
150impl SafetyNetPolicy {
151    pub fn new(mode: SafetyNetMode, fallback: SafetyNetFallback) -> Self {
152        Self { mode, fallback }
153    }
154}
155
156/// The stateless PII pseudonymization engine.
157///
158/// `Pipeline` owns the recognizer registry, rule set, locale resolver, and optional audit logger.
159/// Construct once per process and share across requests; create one [`Session`] per conversation
160/// or request.
161///
162/// # Fail-closed
163///
164/// Construction fails if a recognizer fails to initialize, a validator name is unknown, or a policy
165/// cannot be parsed. There is no silent degradation to a weaker detection posture.
166///
167/// # Thread safety
168///
169/// `Pipeline` is `Send + Sync`. Share across threads; create one [`Session`] per request.
170///
171/// # Quick example
172///
173/// ```rust,no_run
174/// use gaze::{CleanDocument, Pipeline, RawDocument, Scope, Session};
175///
176/// let pipeline = Pipeline::builder().build()?;
177/// let session = Session::new(Scope::Ephemeral)?;
178/// let CleanDocument::Text(clean) = pipeline.redact(
179///     &session,
180///     RawDocument::Text("test".into()),
181/// )? else {
182///     panic!("expected text variant");
183/// };
184/// # let _ = clean;
185/// # Ok::<(), Box<dyn std::error::Error>>(())
186/// ```
187#[derive(Clone)]
188pub struct Pipeline {
189    registry: Arc<RecognizerRegistry>,
190    redaction_loggers: Vec<Arc<dyn RedactionLogger>>,
191    safety_nets: Vec<Arc<dyn SafetyNet>>,
192    #[cfg(feature = "bundled-recognizers")]
193    safety_net_registry: Option<Arc<LocaleAwareModelRegistry>>,
194    optimization_config: PipelineOptimizationConfig,
195    restore_boundary_dlp_audit: bool,
196    rules: Vec<Arc<dyn Rule>>,
197}
198
199/// Observer-only safety-net scan result.
200#[derive(Debug, Clone, Default)]
201#[non_exhaustive]
202pub struct SafetyNetResult {
203    /// Number of safety nets registered for this pipeline.
204    pub nets_run: usize,
205    /// Metadata-only leak report aggregated across scanned leaves.
206    pub report: LeakReport,
207}
208
209impl std::fmt::Debug for Pipeline {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("Pipeline").finish_non_exhaustive()
212    }
213}
214
215impl Pipeline {
216    pub fn builder() -> PipelineBuilder {
217        PipelineBuilder::default()
218    }
219
220    pub fn with_redaction_logger<L>(mut self, logger: L) -> Pipeline
221    where
222        L: RedactionLogger + 'static,
223    {
224        self.redaction_loggers.push(Arc::new(logger));
225        self
226    }
227
228    pub fn with_safety_net<N>(mut self, safety_net: N) -> Pipeline
229    where
230        N: SafetyNet + 'static,
231    {
232        self.safety_nets.push(Arc::new(safety_net));
233        self
234    }
235
236    pub fn restore_with_telemetry(
237        &self,
238        session: &Session,
239        text: &str,
240    ) -> Result<(RestoredText, RestoreTelemetry)> {
241        self.restore_with_policy_telemetry(session, text, RestorePolicy::Strict)
242    }
243
244    pub fn restore_with_policy_telemetry(
245        &self,
246        session: &Session,
247        text: &str,
248        policy: RestorePolicy,
249    ) -> Result<(RestoredText, RestoreTelemetry)> {
250        let mut telemetry = RestoreTelemetry::new(policy);
251        telemetry.phase_execution_mask |= RESTORE_PHASE_MANIFEST_LOOKUP;
252        let restored = restore_known_tokens(session, text)?;
253        telemetry.phase_execution_mask |=
254            RESTORE_PHASE_UNKNOWN_TOKEN_SCAN | RESTORE_PHASE_MANIFEST_BYPASS_SCAN;
255        let unknown_token_count = count_unknown_restore_tokens(session, &restored);
256        telemetry.unknown_token_count = unknown_token_count;
257        telemetry.manifest_bypass_count = unknown_token_count;
258        telemetry.restore_decision = match (policy, unknown_token_count) {
259            (_, 0) => RestoreDecision::Success,
260            (RestorePolicy::Strict, _) => RestoreDecision::Failed,
261            (RestorePolicy::Lenient, _) => RestoreDecision::Partial,
262            (_, _) => RestoreDecision::Failed,
263        };
264        Ok((RestoredText::new(restored), telemetry))
265    }
266
267    pub fn with_pipeline_optimizations(mut self, config: PipelineOptimizationConfig) -> Pipeline {
268        self.optimization_config = config;
269        self
270    }
271
272    #[cfg(feature = "bundled-recognizers")]
273    pub fn with_safety_net_registry(mut self, registry: LocaleAwareModelRegistry) -> Pipeline {
274        self.safety_net_registry = Some(Arc::new(registry));
275        self
276    }
277
278    /// Pseudonymizes using the default `[Global]` locale chain.
279    ///
280    /// Prefer `pseudonymize_with_context` when policy, CLI, or rulepack locale
281    /// defaults should constrain which recognizers run.
282    pub fn redact(&self, session: &Session, raw: RawDocument) -> Result<CleanDocument> {
283        let locale_chain = [crate::LocaleTag::Global];
284        self.pseudonymize_with_context(session, raw, &locale_chain)
285    }
286
287    pub fn pseudonymize_with_context(
288        &self,
289        session: &Session,
290        raw: RawDocument,
291        locale_chain: &[crate::LocaleTag],
292    ) -> Result<CleanDocument> {
293        let dictionaries = DictionaryBundle::default();
294        self.pseudonymize_with_detect_context(session, raw, locale_chain, &dictionaries)
295    }
296
297    pub fn pseudonymize_with_detect_context(
298        &self,
299        session: &Session,
300        raw: RawDocument,
301        locale_chain: &[crate::LocaleTag],
302        dictionaries: &DictionaryBundle,
303    ) -> Result<CleanDocument> {
304        match raw {
305            RawDocument::Structured(structured_fields) => redact_structured(
306                self,
307                session,
308                structured_fields,
309                DocumentKind::Structured,
310                locale_chain,
311                dictionaries,
312            ),
313            RawDocument::Text(text) => Ok(CleanDocument::Text(self.pseudonymize_text(
314                session,
315                &text,
316                None,
317                DocumentKind::Text,
318                locale_chain,
319                dictionaries,
320            )?)),
321            _ => Err(Error::UnsupportedRawDocumentVariant),
322        }
323    }
324
325    pub fn clean_with_safety_net(
326        &self,
327        session: &Session,
328        raw: RawDocument,
329        locale_chain: &[crate::LocaleTag],
330    ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
331        let dictionaries = DictionaryBundle::default();
332        self.clean_with_safety_net_detect_context(session, raw, locale_chain, &dictionaries)
333    }
334
335    pub fn clean_with_safety_net_detect_context(
336        &self,
337        session: &Session,
338        raw: RawDocument,
339        locale_chain: &[crate::LocaleTag],
340        dictionaries: &DictionaryBundle,
341    ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
342        self.clean_with_safety_net_policy_detect_context(
343            session,
344            raw,
345            locale_chain,
346            dictionaries,
347            SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
348        )
349    }
350
351    pub fn clean_with_safety_net_policy_detect_context(
352        &self,
353        session: &Session,
354        raw: RawDocument,
355        locale_chain: &[crate::LocaleTag],
356        dictionaries: &DictionaryBundle,
357        policy: SafetyNetPolicy,
358    ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
359        match raw {
360            RawDocument::Structured(structured_fields) => {
361                let mut report = LeakReport::default();
362                let clean = redact_structured_with_safety_net(
363                    self,
364                    session,
365                    structured_fields,
366                    locale_chain,
367                    dictionaries,
368                    &mut report,
369                    policy,
370                )?;
371                Ok((CleanDocument::Structured(clean), Vec::new(), report))
372            }
373            RawDocument::Text(text) => {
374                let mut clean = self.redact_text_with_manifest(
375                    session,
376                    &text,
377                    None,
378                    DocumentKind::Text,
379                    locale_chain,
380                    dictionaries,
381                )?;
382                let report = self.run_safety_nets(
383                    session,
384                    &clean.text,
385                    &Manifest::from_spans(clean.manifest.clone()),
386                    DocumentKind::Text,
387                    locale_chain,
388                    None,
389                    policy.mode,
390                )?;
391                self.apply_safety_net_policy(
392                    session,
393                    &mut clean,
394                    &report,
395                    DocumentKind::Text,
396                    locale_chain,
397                    None,
398                    policy,
399                )?;
400                Ok((CleanDocument::Text(clean.text), clean.manifest, report))
401            }
402            _ => Err(Error::UnsupportedRawDocumentVariant),
403        }
404    }
405
406    pub fn scan_safety_nets(
407        &self,
408        session: &Session,
409        clean_text: &str,
410        locale_chain: &[crate::LocaleTag],
411    ) -> Result<SafetyNetResult> {
412        let nets_run = self.safety_nets_len();
413        if nets_run == 0 {
414            return Ok(SafetyNetResult {
415                nets_run,
416                report: LeakReport::default(),
417            });
418        }
419
420        let report = self.run_safety_nets(
421            session,
422            clean_text,
423            &Manifest::default(),
424            DocumentKind::Text,
425            locale_chain,
426            None,
427            SafetyNetMode::Strict,
428        )?;
429        Ok(SafetyNetResult { nets_run, report })
430    }
431
432    pub fn scan_safety_nets_structured(
433        &self,
434        session: &Session,
435        document: &BTreeMap<String, Value>,
436        locale_chain: &[crate::LocaleTag],
437    ) -> Result<SafetyNetResult> {
438        let nets_run = self.safety_nets_len();
439        if nets_run == 0 {
440            return Ok(SafetyNetResult {
441                nets_run,
442                report: LeakReport::default(),
443            });
444        }
445
446        let mut report = LeakReport::default();
447        for (key, value) in document {
448            walk_value_for_safety_net_scan(self, session, value, key, locale_chain, &mut report)?;
449        }
450        Ok(SafetyNetResult { nets_run, report })
451    }
452
453    pub fn restore_strict_text(&self, session: &Session, text: &str) -> Result<String> {
454        match session.restore_strict_text_with_events(text) {
455            Ok((restored, events)) => {
456                if self.restore_boundary_dlp_audit {
457                    self.log_restore_events(session, &events)?;
458                }
459                Ok(restored)
460            }
461            Err(Error::UnknownToken {
462                class,
463                ordinal,
464                raw,
465            }) => {
466                self.log_restore_strict_rejection(session, class.clone(), ordinal)?;
467                Err(Error::UnknownToken {
468                    class,
469                    ordinal,
470                    raw,
471                })
472            }
473            Err(err) => Err(err),
474        }
475    }
476
477    #[allow(clippy::too_many_arguments)]
478    fn pseudonymize_text(
479        &self,
480        session: &Session,
481        text: &str,
482        field_name: Option<&str>,
483        document_kind: DocumentKind,
484        locale_chain: &[crate::LocaleTag],
485        dictionaries: &DictionaryBundle,
486    ) -> Result<String> {
487        Ok(self
488            .redact_text_with_manifest(
489                session,
490                text,
491                field_name,
492                document_kind,
493                locale_chain,
494                dictionaries,
495            )?
496            .text)
497    }
498
499    #[allow(clippy::too_many_arguments)]
500    fn redact_text_with_manifest(
501        &self,
502        session: &Session,
503        text: &str,
504        field_name: Option<&str>,
505        document_kind: DocumentKind,
506        locale_chain: &[crate::LocaleTag],
507        dictionaries: &DictionaryBundle,
508    ) -> Result<CleanText> {
509        if self.optimization_config.prefix_cache {
510            if let Some(hit) = session
511                .lookup_prefix_cache(text)
512                .filter(|hit| hit.raw_len < text.len())
513            {
514                let suffix = &text[hit.raw_len..];
515                let suffix_clean = self.redact_text_with_manifest_uncached(
516                    session,
517                    suffix,
518                    field_name,
519                    document_kind,
520                    locale_chain,
521                    dictionaries,
522                )?;
523                let clean_offset = hit.clean_text.len();
524                let raw_offset = hit.raw_len;
525                let mut manifest = hit.manifest.clone();
526                manifest.extend(suffix_clean.manifest.into_iter().map(|mut span| {
527                    span.clean_span.start += clean_offset;
528                    span.clean_span.end += clean_offset;
529                    span.raw_span.start += raw_offset;
530                    span.raw_span.end += raw_offset;
531                    span
532                }));
533                let mut clean_text = hit.clean_text;
534                clean_text.push_str(&suffix_clean.text);
535                self.log_prefix_cache_entries(
536                    session,
537                    &hit.manifest,
538                    field_name,
539                    document_kind,
540                    locale_chain,
541                )?;
542                session.store_prefix_cache(text, &clean_text, &manifest);
543                return Ok(CleanText {
544                    text: clean_text,
545                    manifest,
546                });
547            }
548        }
549
550        let clean = self.redact_text_with_manifest_uncached(
551            session,
552            text,
553            field_name,
554            document_kind,
555            locale_chain,
556            dictionaries,
557        )?;
558        if self.optimization_config.prefix_cache {
559            session.store_prefix_cache(text, &clean.text, &clean.manifest);
560        }
561        Ok(clean)
562    }
563
564    #[allow(clippy::too_many_arguments)]
565    fn redact_text_with_manifest_uncached(
566        &self,
567        session: &Session,
568        text: &str,
569        field_name: Option<&str>,
570        document_kind: DocumentKind,
571        locale_chain: &[crate::LocaleTag],
572        dictionaries: &DictionaryBundle,
573    ) -> Result<CleanText> {
574        let normalized = normalize(text);
575        let spans = &normalized.spans;
576        let ctx = DetectContext::new(locale_chain, dictionaries);
577        let (resolved, vetoed) = self.registry.detect_all_resolved(&normalized.text, &ctx)?;
578        let vetoed = vetoed
579            .into_iter()
580            .filter_map(|vetoed| translate_vetoed_candidate(vetoed, spans))
581            .collect::<Vec<_>>();
582        let resolved = resolved
583            .into_iter()
584            .filter_map(|candidate| translate_candidate(candidate, spans))
585            .collect::<Vec<_>>();
586        let losers = merged_losers(&resolved, &self.registry);
587        let mut detections = resolved
588            .into_iter()
589            .map(|candidate| indexed_detection_from_candidate(candidate, &self.registry))
590            .collect::<Vec<_>>();
591        for loser in &losers {
592            self.log_entry(
593                session,
594                loser,
595                field_name,
596                document_kind,
597                self.action_for(&loser.detection, &build_context(field_name)),
598                true,
599            )?;
600        }
601        for vetoed in &vetoed {
602            self.log_vetoed_entry(session, vetoed, field_name, document_kind)?;
603        }
604
605        detections.sort_by_key(|d| d.detection.span.start);
606        let mut out = String::with_capacity(text.len());
607        let mut emitted = Vec::with_capacity(detections.len());
608        let mut cursor = 0usize;
609
610        for detection in detections {
611            let raw = text[detection.detection.span.clone()].to_string();
612            let context = build_context(field_name);
613            let action = self.action_for(&detection.detection, &context);
614            self.log_entry(
615                session,
616                &detection,
617                field_name,
618                document_kind,
619                action,
620                false,
621            )?;
622
623            let replacement = match action {
624                Action::Tokenize => Some(session.tokenize_with_family(
625                    &detection.family,
626                    &detection.detection.class,
627                    &raw,
628                )?),
629                Action::Redact => Some("[REDACTED]".to_string()),
630                Action::FormatPreserve => {
631                    Some(session.format_preserving_fake(&detection.detection.class, &raw)?)
632                }
633                Action::Generalize => Some(generalize_token(&detection.detection.class)),
634                Action::Preserve => None,
635                _ => return Err(Error::UnsupportedActionVariant),
636            };
637
638            let span = detection.detection.span;
639            if span.start > cursor {
640                out.push_str(&text[cursor..span.start]);
641            }
642            match replacement {
643                Some(replacement) => {
644                    let clean_start = out.len();
645                    out.push_str(&replacement);
646                    emitted.push(EmittedTokenSpan::new(
647                        clean_start..out.len(),
648                        span.clone(),
649                        detection.detection.class,
650                    ));
651                }
652                None => out.push_str(&text[span.clone()]),
653            }
654            cursor = span.end;
655        }
656
657        if cursor < text.len() {
658            out.push_str(&text[cursor..]);
659        }
660
661        Ok(CleanText {
662            text: out,
663            manifest: emitted,
664        })
665    }
666
667    #[allow(clippy::too_many_arguments)]
668    fn run_safety_nets(
669        &self,
670        session: &Session,
671        clean_text: &str,
672        manifest: &Manifest,
673        document_kind: DocumentKind,
674        locale_chain: &[crate::LocaleTag],
675        field_path: Option<&str>,
676        safety_net_mode: SafetyNetMode,
677    ) -> Result<LeakReport> {
678        if self.safety_nets_len() == 0 {
679            return Ok(LeakReport::default());
680        }
681        if self.should_skip_safety_nets(clean_text, manifest, locale_chain, safety_net_mode)? {
682            return Ok(LeakReport::default());
683        }
684
685        let mut suspects = Vec::<LeakSuspect>::new();
686        let mut telemetry = Vec::new();
687        let active = gaze_types::LocaleChain::from(locale_chain);
688        for net in &self.safety_nets {
689            if !active.intersects(net.supported_locales()) {
690                telemetry.push(LeakReportTelemetry::LocaleSkipped {
691                    safety_net_id: net.id().to_string(),
692                    document_kind,
693                    field_path: field_path.map(str::to_string),
694                });
695                continue;
696            }
697
698            let context = SafetyNetContext::new(
699                manifest,
700                locale_chain,
701                document_kind,
702                Some(session.audit_session_id()),
703                field_path,
704            );
705            let mut reported = net.check(clean_text, context)?;
706            if let Some(path) = field_path {
707                for suspect in &mut reported {
708                    if suspect.field_path.is_none() {
709                        suspect.field_path = Some(path.to_string());
710                    }
711                }
712            }
713            suspects.extend(reported);
714        }
715        #[cfg(feature = "bundled-recognizers")]
716        if let Some(registry) = &self.safety_net_registry {
717            let locale = locale_chain
718                .first()
719                .cloned()
720                .unwrap_or(crate::LocaleTag::Global);
721            let selected = registry
722                .resolve(&locale, ModelStage::Pass3SafetyNet)
723                .map_err(model_error_to_safety_net_error)?;
724            if selected.len() > 1 {
725                let selected_backend = selected[0].name();
726                let dropped = selected
727                    .iter()
728                    .skip(1)
729                    .map(|backend| backend.name().to_string())
730                    .collect::<Vec<_>>();
731                tracing::debug!(
732                    selected_backend,
733                    backend_silently_dropped = ?dropped,
734                    "locale-aware safety-net registry resolved multiple backends; using first"
735                );
736                self.log_backend_silently_dropped(
737                    session,
738                    document_kind,
739                    field_path,
740                    selected_backend,
741                    dropped,
742                )?;
743            }
744            if let Some(model) = selected.first() {
745                let spans = model
746                    .infer(
747                        ModelInput {
748                            text: clean_text.to_string(),
749                            locale,
750                        },
751                        ModelHints {
752                            stage: ModelStage::Pass3SafetyNet,
753                            max_spans: None,
754                        },
755                    )
756                    .map_err(model_error_to_safety_net_error)?;
757                for span in spans {
758                    if let Some(suspect) =
759                        model_span_to_suspect(span, model.name(), manifest, field_path)
760                    {
761                        suspects.push(suspect);
762                    }
763                }
764            }
765        }
766
767        Ok(LeakReport::from_parts(suspects, telemetry))
768    }
769
770    fn should_skip_safety_nets(
771        &self,
772        clean_text: &str,
773        manifest: &Manifest,
774        locale_chain: &[crate::LocaleTag],
775        safety_net_mode: SafetyNetMode,
776    ) -> Result<bool> {
777        if !matches!(
778            safety_net_mode,
779            SafetyNetMode::Strict | SafetyNetMode::Tolerant
780        ) {
781            return Ok(false);
782        }
783        if self.optimization_config.capitals_heuristic_gate {
784            validate_capitals_gate_locales(locale_chain)?;
785            if is_numeric_heavy(clean_text) || !has_non_sentence_start_capital(clean_text) {
786                return Ok(true);
787            }
788        }
789        if self.optimization_config.skip_class_gating
790            && !manifest.spans.is_empty()
791            && !has_residual_gold_shape(clean_text)
792        {
793            return Ok(true);
794        }
795        Ok(false)
796    }
797
798    fn safety_nets_len(&self) -> usize {
799        let single = self.safety_nets.len();
800        #[cfg(feature = "bundled-recognizers")]
801        {
802            single
803                + self
804                    .safety_net_registry
805                    .as_ref()
806                    .map_or(0, |registry| usize::from(!registry.is_empty()))
807        }
808        #[cfg(not(feature = "bundled-recognizers"))]
809        {
810            single
811        }
812    }
813
814    #[allow(clippy::too_many_arguments)]
815    fn apply_safety_net_policy(
816        &self,
817        session: &Session,
818        clean: &mut CleanText,
819        report: &LeakReport,
820        document_kind: DocumentKind,
821        locale_chain: &[crate::LocaleTag],
822        field_path: Option<&str>,
823        policy: SafetyNetPolicy,
824    ) -> Result<()> {
825        match policy.mode {
826            SafetyNetMode::Strict | SafetyNetMode::Tolerant => Ok(()),
827            SafetyNetMode::Redact => self.redact_safety_net_suspects(
828                session,
829                clean,
830                report,
831                document_kind,
832                field_path,
833                None,
834                true,
835            ),
836            SafetyNetMode::Resolve => {
837                let reason = match self.resolve_safety_net_suspects(
838                    session,
839                    clean,
840                    report,
841                    document_kind,
842                    field_path,
843                )? {
844                    Some(reason) => Some(reason),
845                    None => {
846                        let follow_up = self.run_safety_nets(
847                            session,
848                            &clean.text,
849                            &Manifest::from_spans(clean.manifest.clone()),
850                            document_kind,
851                            locale_chain,
852                            field_path,
853                            SafetyNetMode::Resolve,
854                        )?;
855                        (follow_up.stats.uncovered_count + follow_up.stats.partial_bleed_count > 0)
856                            .then_some(FallbackReason::ResidualSuspect)
857                    }
858                };
859                if let Some(reason) = reason {
860                    self.apply_safety_net_fallback(
861                        session,
862                        clean,
863                        report,
864                        document_kind,
865                        field_path,
866                        policy.fallback,
867                        reason,
868                    )?;
869                }
870                Ok(())
871            }
872        }
873    }
874
875    fn resolve_safety_net_suspects(
876        &self,
877        session: &Session,
878        clean: &mut CleanText,
879        report: &LeakReport,
880        document_kind: DocumentKind,
881        field_path: Option<&str>,
882    ) -> Result<Option<FallbackReason>> {
883        if report
884            .suspects
885            .iter()
886            .any(|suspect| matches!(suspect.kind, LeakKind::ClassMismatch { .. }))
887        {
888            return Ok(Some(FallbackReason::OverlapConflict));
889        }
890        for suspect in actionable_suspects(report).into_iter().rev() {
891            let span = suspect_action_span(suspect);
892            if !is_char_boundary_range(&clean.text, &span) {
893                return Ok(Some(FallbackReason::ResidualSuspect));
894            }
895            let raw = clean.text[span.clone()].to_string();
896            let replacement = session.tokenize_with_family("safety_net", &suspect.class, &raw)?;
897            self.log_safety_net_entry(
898                session,
899                suspect,
900                document_kind,
901                field_path,
902                Action::Tokenize,
903                false,
904                ConflictTier::Resolve,
905                None,
906            )?;
907            replace_clean_span(
908                clean,
909                span.clone(),
910                &replacement,
911                Some(EmittedTokenSpan::new(
912                    span.start..span.start + replacement.len(),
913                    span,
914                    suspect.class.clone(),
915                )),
916            );
917        }
918        Ok(None)
919    }
920
921    #[allow(clippy::too_many_arguments)]
922    fn apply_safety_net_fallback(
923        &self,
924        session: &Session,
925        clean: &mut CleanText,
926        report: &LeakReport,
927        document_kind: DocumentKind,
928        field_path: Option<&str>,
929        fallback: SafetyNetFallback,
930        reason: FallbackReason,
931    ) -> Result<()> {
932        for suspect in redaction_suspects(report) {
933            self.log_safety_net_entry(
934                session,
935                suspect,
936                document_kind,
937                field_path,
938                fallback_action(fallback),
939                true,
940                ConflictTier::Fallback,
941                Some(reason),
942            )?;
943        }
944        match fallback {
945            SafetyNetFallback::Strict => Err(Error::SafetyNetFallback(reason)),
946            SafetyNetFallback::Tolerant => Ok(()),
947            SafetyNetFallback::Redact => self.redact_safety_net_suspects(
948                session,
949                clean,
950                report,
951                document_kind,
952                field_path,
953                Some(reason),
954                false,
955            ),
956        }
957    }
958
959    #[allow(clippy::too_many_arguments)]
960    fn redact_safety_net_suspects(
961        &self,
962        session: &Session,
963        clean: &mut CleanText,
964        report: &LeakReport,
965        document_kind: DocumentKind,
966        field_path: Option<&str>,
967        fallback_reason: Option<FallbackReason>,
968        log_entries: bool,
969    ) -> Result<()> {
970        for suspect in redaction_suspects(report).into_iter().rev() {
971            let span =
972                round_span_outward_to_char_boundaries(&clean.text, suspect_action_span(suspect))?;
973            let span = expand_span_to_overlapping_manifest_entries(clean, span);
974            for existing in clean
975                .manifest
976                .iter()
977                .filter(|existing| ranges_overlap(&existing.clean_span, &span))
978            {
979                tracing::warn!(
980                    class = ?existing.class,
981                    clean_span_start = existing.clean_span.start,
982                    clean_span_end = existing.clean_span.end,
983                    "safety net redaction dropping overlapping manifest entry"
984                );
985            }
986            if log_entries {
987                self.log_safety_net_entry(
988                    session,
989                    suspect,
990                    document_kind,
991                    field_path,
992                    Action::Redact,
993                    fallback_reason.is_some(),
994                    if fallback_reason.is_some() {
995                        ConflictTier::Fallback
996                    } else {
997                        ConflictTier::Redact
998                    },
999                    fallback_reason,
1000                )?;
1001            }
1002            replace_clean_span(clean, span, "", None);
1003        }
1004        Ok(())
1005    }
1006
1007    #[allow(clippy::too_many_arguments)]
1008    fn log_safety_net_entry(
1009        &self,
1010        session: &Session,
1011        suspect: &LeakSuspect,
1012        document_kind: DocumentKind,
1013        field_path: Option<&str>,
1014        action: Action,
1015        conflict_loser: bool,
1016        decided_by: ConflictTier,
1017        fallback_reason: Option<FallbackReason>,
1018    ) -> Result<()> {
1019        let source = format!("safety_net.{}", suspect.safety_net_id);
1020        let mut entry = RedactionEntry::new(
1021            source.clone(),
1022            suspect.class.clone(),
1023            action,
1024            field_path
1025                .map(str::to_string)
1026                .or_else(|| suspect.field_path.clone()),
1027            document_kind,
1028            conflict_loser,
1029            decided_by,
1030            crate::redaction_log::current_epoch_ms(),
1031            Some(session.audit_session_id().to_string()),
1032        )
1033        .with_recognizer_metadata(Some(source), None);
1034        if let Some(reason) = fallback_reason {
1035            entry = entry.with_fallback_triggered(reason);
1036        }
1037        for logger in &self.redaction_loggers {
1038            logger.log(&entry)?;
1039        }
1040        Ok(())
1041    }
1042
1043    fn log_backend_silently_dropped(
1044        &self,
1045        session: &Session,
1046        document_kind: DocumentKind,
1047        field_path: Option<&str>,
1048        selected_backend: &str,
1049        dropped: Vec<String>,
1050    ) -> Result<()> {
1051        let entry = RedactionEntry::new(
1052            format!("safety_net.{selected_backend}"),
1053            PiiClass::Custom("safety_net.backend".to_string()),
1054            Action::Preserve,
1055            field_path.map(str::to_string),
1056            document_kind,
1057            true,
1058            ConflictTier::None,
1059            crate::redaction_log::current_epoch_ms(),
1060            Some(session.audit_session_id().to_string()),
1061        )
1062        .with_backend_silently_dropped(dropped);
1063        for logger in &self.redaction_loggers {
1064            logger.log(&entry)?;
1065        }
1066        Ok(())
1067    }
1068
1069    fn action_for(&self, detection: &Detection, context: &RuleContext) -> Action {
1070        self.rules
1071            .iter()
1072            .find_map(|rule| rule.action(&detection.class, context))
1073            .unwrap_or(Action::Preserve)
1074    }
1075
1076    fn log_entry(
1077        &self,
1078        session: &Session,
1079        detection: &IndexedDetection,
1080        field_name: Option<&str>,
1081        document_kind: DocumentKind,
1082        action: Action,
1083        conflict_loser: bool,
1084    ) -> Result<()> {
1085        let mut entry = RedactionEntry::new(
1086            detection.detection.source.clone(),
1087            detection.detection.class.clone(),
1088            action,
1089            field_name.map(str::to_string),
1090            document_kind,
1091            conflict_loser,
1092            detection.decided_by,
1093            crate::redaction_log::current_epoch_ms(),
1094            Some(session.audit_session_id().to_string()),
1095        )
1096        .with_recognizer_metadata(
1097            detection.recognizer_id.clone(),
1098            detection.recognizer_version_id.clone(),
1099        );
1100        if let Some(record) = detection.ambiguity_record.clone() {
1101            entry = entry.with_ambiguity_record(record);
1102        }
1103        if detection.collision_family.is_some() || detection.collision_variant.is_some() {
1104            entry = entry.with_collision_metadata(
1105                detection.collision_family.clone(),
1106                detection.collision_variant.clone(),
1107            );
1108        }
1109
1110        for logger in &self.redaction_loggers {
1111            logger.log(&entry)?;
1112        }
1113
1114        Ok(())
1115    }
1116
1117    fn log_prefix_cache_entries(
1118        &self,
1119        session: &Session,
1120        manifest: &[EmittedTokenSpan],
1121        field_name: Option<&str>,
1122        document_kind: DocumentKind,
1123        locale_chain: &[crate::LocaleTag],
1124    ) -> Result<()> {
1125        let locale = locale_chain
1126            .first()
1127            .map(crate::LocaleTag::as_str)
1128            .unwrap_or("global")
1129            .to_string();
1130        for span in manifest {
1131            let entry = RedactionEntry::new(
1132                "prefix_cache",
1133                span.class.clone(),
1134                Action::Tokenize,
1135                field_name.map(str::to_string),
1136                document_kind,
1137                false,
1138                ConflictTier::None,
1139                crate::redaction_log::current_epoch_ms(),
1140                Some(session.audit_session_id().to_string()),
1141            )
1142            .with_recognizer_metadata(Some("prefix_cache".to_string()), None)
1143            .with_provenance_metadata(
1144                Some("prefix_cache".to_string()),
1145                None,
1146                None,
1147                None,
1148                None,
1149                Some(locale.clone()),
1150                Some("session_prefix".to_string()),
1151                Some(span.class.to_canonical_str()),
1152                Some(span.class.to_canonical_str()),
1153                None,
1154                None,
1155            );
1156            for logger in &self.redaction_loggers {
1157                logger.log(&entry)?;
1158            }
1159        }
1160        Ok(())
1161    }
1162
1163    fn log_restore_strict_rejection(
1164        &self,
1165        session: &Session,
1166        class: PiiClass,
1167        ordinal: u32,
1168    ) -> Result<()> {
1169        let entry = RedactionEntry::new(
1170            "restore_strict",
1171            class.clone(),
1172            Action::Preserve,
1173            None,
1174            DocumentKind::Text,
1175            true,
1176            ConflictTier::None,
1177            crate::redaction_log::current_epoch_ms(),
1178            Some(session.audit_session_id().to_string()),
1179        )
1180        .with_recognizer_metadata(Some("restore_strict".to_string()), None)
1181        .with_provenance_metadata(
1182            Some("restore_strict".to_string()),
1183            None,
1184            None,
1185            None,
1186            None,
1187            None,
1188            None,
1189            Some(class.to_canonical_str()),
1190            Some(class.to_canonical_str()),
1191            None,
1192            Some(format!("ordinal:{ordinal}")),
1193        );
1194        for logger in &self.redaction_loggers {
1195            logger.log(&entry)?;
1196        }
1197        Ok(())
1198    }
1199
1200    fn log_restore_events(&self, session: &Session, events: &[RestoreEvent]) -> Result<()> {
1201        for event in events {
1202            let entry = RedactionEntry::new(
1203                event.kind.as_str(),
1204                event.class.clone(),
1205                Action::Preserve,
1206                None,
1207                DocumentKind::Text,
1208                false,
1209                ConflictTier::None,
1210                crate::redaction_log::current_epoch_ms(),
1211                Some(session.audit_session_id().to_string()),
1212            )
1213            .with_recognizer_metadata(Some("restore_boundary_dlp".to_string()), None)
1214            .with_provenance_metadata(
1215                Some("restore_boundary_dlp".to_string()),
1216                None,
1217                None,
1218                None,
1219                None,
1220                None,
1221                Some(event.kind.as_str().to_string()),
1222                Some(event.class.to_canonical_str()),
1223                Some(event.class.to_canonical_str()),
1224                None,
1225                Some(format!(
1226                    "sha256:{};span:{}..{}",
1227                    event.raw_sha256, event.location.start, event.location.end
1228                )),
1229            );
1230            for logger in &self.redaction_loggers {
1231                logger.log(&entry)?;
1232            }
1233        }
1234        Ok(())
1235    }
1236
1237    fn log_vetoed_entry(
1238        &self,
1239        session: &Session,
1240        vetoed: &crate::validator_veto::VetoedCandidate,
1241        field_name: Option<&str>,
1242        document_kind: DocumentKind,
1243    ) -> Result<()> {
1244        let entry = RedactionEntry::new(
1245            vetoed.candidate.source.clone(),
1246            vetoed.candidate.class.clone(),
1247            self.action_for(
1248                &Detection::new(
1249                    vetoed.candidate.span.clone(),
1250                    vetoed.candidate.class.clone(),
1251                    vetoed.candidate.source.clone(),
1252                ),
1253                &build_context(field_name),
1254            ),
1255            field_name.map(str::to_string),
1256            document_kind,
1257            true,
1258            ConflictTier::ValidatorVeto,
1259            crate::redaction_log::current_epoch_ms(),
1260            Some(session.audit_session_id().to_string()),
1261        )
1262        .with_recognizer_metadata(
1263            Some(vetoed.candidate.recognizer_id.clone()),
1264            vetoed.candidate.recognizer_version_id.clone(),
1265        )
1266        .with_validator_fail_reason(vetoed.reason);
1267
1268        for logger in &self.redaction_loggers {
1269            logger.log(&entry)?;
1270        }
1271
1272        Ok(())
1273    }
1274}
1275
1276#[derive(Clone)]
1277struct IndexedDetection {
1278    detection: Detection,
1279    recognizer_id: Option<String>,
1280    recognizer_version_id: Option<String>,
1281    decided_by: ConflictTier,
1282    family: String,
1283    ambiguity_record: Option<AmbiguityRecord>,
1284    collision_family: Option<String>,
1285    collision_variant: Option<String>,
1286}
1287
1288struct CleanText {
1289    text: String,
1290    manifest: Vec<EmittedTokenSpan>,
1291}
1292
1293fn actionable_suspects(report: &LeakReport) -> Vec<&LeakSuspect> {
1294    let mut suspects = report
1295        .suspects
1296        .iter()
1297        .filter(|suspect| {
1298            matches!(
1299                suspect.kind,
1300                LeakKind::Uncovered | LeakKind::PartialBleed { .. }
1301            )
1302        })
1303        .collect::<Vec<_>>();
1304    suspects.sort_by_key(|suspect| suspect_action_span(suspect).start);
1305    suspects
1306}
1307
1308fn redaction_suspects(report: &LeakReport) -> Vec<&LeakSuspect> {
1309    let mut suspects = report.suspects.iter().collect::<Vec<_>>();
1310    suspects.sort_by_key(|suspect| suspect_action_span(suspect).start);
1311    suspects
1312}
1313
1314fn suspect_action_span(suspect: &LeakSuspect) -> Range<usize> {
1315    match &suspect.kind {
1316        LeakKind::PartialBleed { uncovered } => uncovered.clone(),
1317        _ => suspect.span.clone(),
1318    }
1319}
1320
1321fn fallback_action(fallback: SafetyNetFallback) -> Action {
1322    match fallback {
1323        SafetyNetFallback::Strict | SafetyNetFallback::Tolerant => Action::Preserve,
1324        SafetyNetFallback::Redact => Action::Redact,
1325    }
1326}
1327
1328fn is_char_boundary_range(text: &str, span: &Range<usize>) -> bool {
1329    span.start <= span.end
1330        && span.end <= text.len()
1331        && text.is_char_boundary(span.start)
1332        && text.is_char_boundary(span.end)
1333}
1334
1335fn round_span_outward_to_char_boundaries(text: &str, span: Range<usize>) -> Result<Range<usize>> {
1336    if span.start > span.end || span.end > text.len() {
1337        return Err(Error::SafetyNetSpanInvalid {
1338            start: span.start,
1339            end: span.end,
1340            text_len: text.len(),
1341        });
1342    }
1343
1344    let mut start = span.start;
1345    while start > 0 && !text.is_char_boundary(start) {
1346        start -= 1;
1347    }
1348
1349    let mut end = span.end;
1350    while end < text.len() && !text.is_char_boundary(end) {
1351        end += 1;
1352    }
1353
1354    if !is_char_boundary_range(text, &(start..end)) {
1355        return Err(Error::SafetyNetSpanInvalid {
1356            start: span.start,
1357            end: span.end,
1358            text_len: text.len(),
1359        });
1360    }
1361
1362    Ok(start..end)
1363}
1364
1365fn expand_span_to_overlapping_manifest_entries(
1366    clean: &CleanText,
1367    span: Range<usize>,
1368) -> Range<usize> {
1369    let mut expanded = span;
1370    loop {
1371        let mut changed = false;
1372        for existing in &clean.manifest {
1373            if ranges_overlap(&existing.clean_span, &expanded) {
1374                let start = expanded.start.min(existing.clean_span.start);
1375                let end = expanded.end.max(existing.clean_span.end);
1376                changed |= start != expanded.start || end != expanded.end;
1377                expanded = start..end;
1378            }
1379        }
1380        if !changed {
1381            return expanded;
1382        }
1383    }
1384}
1385
1386fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1387    left.start < right.end && right.start < left.end
1388}
1389
1390fn validate_capitals_gate_locales(locale_chain: &[crate::LocaleTag]) -> Result<()> {
1391    for locale in locale_chain {
1392        if !capital_case_locale_supported(locale) {
1393            return Err(Error::UnsupportedCapitalHeuristicLocale {
1394                locale: locale.as_str().to_string(),
1395            });
1396        }
1397    }
1398    Ok(())
1399}
1400
1401fn capital_case_locale_supported(locale: &crate::LocaleTag) -> bool {
1402    matches!(
1403        locale,
1404        crate::LocaleTag::Global
1405            | crate::LocaleTag::DeDe
1406            | crate::LocaleTag::DeAt
1407            | crate::LocaleTag::DeCh
1408            | crate::LocaleTag::EnUs
1409            | crate::LocaleTag::EnGb
1410            | crate::LocaleTag::EnIe
1411            | crate::LocaleTag::EnAu
1412            | crate::LocaleTag::EnCa
1413    )
1414}
1415
1416fn is_numeric_heavy(text: &str) -> bool {
1417    let digits = text.chars().filter(|ch| ch.is_ascii_digit()).count();
1418    let letters = text.chars().filter(|ch| ch.is_alphabetic()).count();
1419    digits >= 6 && digits > letters.saturating_mul(2)
1420}
1421
1422// This heuristic is only recall-preserving for locales with capital-case name
1423// conventions (currently English and German). Arabic, CJK, and other scripts
1424// must fail closed through `validate_capitals_gate_locales`.
1425fn has_non_sentence_start_capital(text: &str) -> bool {
1426    let mut sentence_start = true;
1427    for ch in text.chars() {
1428        if ch.is_whitespace() {
1429            continue;
1430        }
1431        if ch.is_uppercase() && !sentence_start {
1432            return true;
1433        }
1434        sentence_start = matches!(ch, '.' | '!' | '?');
1435    }
1436    false
1437}
1438
1439fn has_residual_gold_shape(text: &str) -> bool {
1440    text.contains('@') || is_numeric_heavy(text)
1441}
1442
1443fn replace_clean_span(
1444    clean: &mut CleanText,
1445    span: Range<usize>,
1446    replacement: &str,
1447    emitted: Option<EmittedTokenSpan>,
1448) {
1449    let removed_len = span.end - span.start;
1450    let replacement_len = replacement.len();
1451    clean.text.replace_range(span.clone(), replacement);
1452    clean.manifest = clean
1453        .manifest
1454        .iter()
1455        .filter_map(|existing| adjust_emitted_span(existing, &span, replacement_len, removed_len))
1456        .chain(emitted)
1457        .collect();
1458    clean.manifest.sort_by_key(|span| span.clean_span.start);
1459}
1460
1461fn restore_known_tokens(session: &Session, text: &str) -> Result<String> {
1462    let Some(re) = session.restore_regex()? else {
1463        return Ok(text.to_string());
1464    };
1465    let mut out = String::with_capacity(text.len());
1466    let mut last = 0usize;
1467    for matched in re.find_iter(text) {
1468        out.push_str(&text[last..matched.start()]);
1469        out.push_str(&session.restore_strict(matched.as_str())?);
1470        last = matched.end();
1471    }
1472    out.push_str(&text[last..]);
1473    Ok(out)
1474}
1475
1476fn count_unknown_restore_tokens(session: &Session, text: &str) -> u64 {
1477    crate::token_shape::pattern()
1478        .find_iter(text)
1479        .filter(|matched| {
1480            let matched_text = matched.as_str();
1481            crate::token_shape::is_trap(matched_text) || !session.contains_token(matched_text)
1482        })
1483        .count() as u64
1484}
1485
1486fn adjust_emitted_span(
1487    existing: &EmittedTokenSpan,
1488    edited: &Range<usize>,
1489    replacement_len: usize,
1490    removed_len: usize,
1491) -> Option<EmittedTokenSpan> {
1492    if existing.clean_span.start < edited.end && edited.start < existing.clean_span.end {
1493        return None;
1494    }
1495    let mut span = existing.clone();
1496    if span.clean_span.start >= edited.end {
1497        if replacement_len >= removed_len {
1498            let delta = replacement_len - removed_len;
1499            span.clean_span.start += delta;
1500            span.clean_span.end += delta;
1501        } else {
1502            let delta = removed_len - replacement_len;
1503            span.clean_span.start -= delta;
1504            span.clean_span.end -= delta;
1505        }
1506    }
1507    Some(span)
1508}
1509
1510/// Builder for [`Pipeline`].
1511///
1512/// Obtain via [`Pipeline::builder()`]. Chain `.recognizer()`, `.rule()`, optionally
1513/// `.redaction_logger()` / `.register_safety_net()`, then call `.build()`.
1514///
1515/// For bundled defaults (core rulepack + locale-aware recognizers without manual wiring), use
1516/// `gaze_assembly::CorePipelineConfig` instead.
1517#[derive(Default)]
1518pub struct PipelineBuilder {
1519    recognizers: Vec<Arc<dyn Recognizer>>,
1520    collision_memberships: Vec<(String, CollisionMembership)>,
1521    anchor_cue_bundles: Vec<(crate::LocaleTag, String, Vec<String>, Option<u16>)>,
1522    redaction_loggers: Vec<Arc<dyn RedactionLogger>>,
1523    safety_nets: Vec<Arc<dyn SafetyNet>>,
1524    #[cfg(feature = "bundled-recognizers")]
1525    safety_net_registry: Option<Arc<LocaleAwareModelRegistry>>,
1526    optimization_config: PipelineOptimizationConfig,
1527    restore_boundary_dlp_audit: bool,
1528    rules: Vec<Arc<dyn Rule>>,
1529}
1530
1531impl PipelineBuilder {
1532    pub fn detector<D>(mut self, detector: D) -> Self
1533    where
1534        D: Detector + 'static,
1535    {
1536        self.recognizers
1537            .push(Arc::new(DetectorRecognizer::new(detector)));
1538        self
1539    }
1540
1541    pub fn recognizer<R>(mut self, recognizer: R) -> Self
1542    where
1543        R: Recognizer + 'static,
1544    {
1545        self.recognizers.push(Arc::new(recognizer));
1546        self
1547    }
1548
1549    pub fn register_collision(
1550        mut self,
1551        recognizer_id: impl Into<String>,
1552        membership: CollisionMembership,
1553    ) -> Self {
1554        self.collision_memberships
1555            .push((recognizer_id.into(), membership));
1556        self
1557    }
1558
1559    pub fn register_anchor_cue_bundle(
1560        mut self,
1561        locale: crate::LocaleTag,
1562        anchor_key: impl Into<String>,
1563        names: Vec<String>,
1564        window_chars: Option<u16>,
1565    ) -> Self {
1566        self.anchor_cue_bundles
1567            .push((locale, anchor_key.into(), names, window_chars));
1568        self
1569    }
1570
1571    pub fn rule<R>(mut self, rule: R) -> Self
1572    where
1573        R: Rule + 'static,
1574    {
1575        self.rules.push(Arc::new(rule));
1576        self
1577    }
1578
1579    pub fn redaction_logger<L>(mut self, logger: L) -> Self
1580    where
1581        L: RedactionLogger + 'static,
1582    {
1583        self.redaction_loggers.push(Arc::new(logger));
1584        self
1585    }
1586
1587    pub fn register_safety_net<N>(mut self, safety_net: N) -> Self
1588    where
1589        N: SafetyNet + 'static,
1590    {
1591        self.safety_nets.push(Arc::new(safety_net));
1592        self
1593    }
1594
1595    pub fn pipeline_optimizations(mut self, config: PipelineOptimizationConfig) -> Self {
1596        self.optimization_config = config;
1597        self
1598    }
1599
1600    pub fn enable_restore_boundary_dlp_audit(mut self) -> Self {
1601        self.restore_boundary_dlp_audit = true;
1602        self
1603    }
1604
1605    pub fn enable_skip_class_gating(mut self) -> Self {
1606        self.optimization_config.skip_class_gating = true;
1607        self
1608    }
1609
1610    pub fn enable_capitals_heuristic_gate(mut self) -> Self {
1611        self.optimization_config.capitals_heuristic_gate = true;
1612        self
1613    }
1614
1615    pub fn enable_prefix_cache(mut self) -> Self {
1616        self.optimization_config.prefix_cache = true;
1617        self
1618    }
1619
1620    pub fn enable_length_bucketing(mut self) -> Self {
1621        self.optimization_config.length_bucketing = true;
1622        self
1623    }
1624
1625    #[cfg(feature = "bundled-recognizers")]
1626    pub fn register_safety_net_registry(mut self, registry: LocaleAwareModelRegistry) -> Self {
1627        self.safety_net_registry = Some(Arc::new(registry));
1628        self
1629    }
1630
1631    pub fn build(self) -> Result<Pipeline> {
1632        let mut registry = RecognizerRegistry::builder();
1633        for recognizer in self.recognizers {
1634            registry = registry.register_arc(recognizer);
1635        }
1636        for (recognizer_id, membership) in self.collision_memberships {
1637            registry = registry.register_collision(recognizer_id, membership);
1638        }
1639        for (locale, anchor_key, names, window_chars) in self.anchor_cue_bundles {
1640            registry = registry.register_anchor_cue_bundle(locale, anchor_key, names, window_chars);
1641        }
1642        Ok(Pipeline {
1643            registry: Arc::new(registry.build()),
1644            redaction_loggers: self.redaction_loggers,
1645            safety_nets: self.safety_nets,
1646            #[cfg(feature = "bundled-recognizers")]
1647            safety_net_registry: self.safety_net_registry,
1648            optimization_config: self.optimization_config,
1649            restore_boundary_dlp_audit: self.restore_boundary_dlp_audit,
1650            rules: self.rules,
1651        })
1652    }
1653}
1654
1655#[cfg(feature = "bundled-recognizers")]
1656fn model_span_to_suspect(
1657    span: ModelSpan,
1658    backend_name: &str,
1659    manifest: &Manifest,
1660    field_path: Option<&str>,
1661) -> Option<LeakSuspect> {
1662    let kind = manifest.diff_against(&span.byte_range, &span.class)?;
1663    Some(LeakSuspect::new(
1664        span.byte_range,
1665        span.class.clone(),
1666        backend_name,
1667        span.confidence,
1668        kind,
1669        format!("{:?}", span.class),
1670        field_path.map(str::to_string),
1671    ))
1672}
1673
1674#[cfg(feature = "bundled-recognizers")]
1675fn model_error_to_safety_net_error(error: ModelError) -> SafetyNetError {
1676    match error {
1677        ModelError::NoLocaleModelCoverage { .. } | ModelError::LocaleNotSupported(_) => {
1678            SafetyNetError::Unavailable {
1679                reason: error.to_string(),
1680            }
1681        }
1682        ModelError::IntegrityMismatch => SafetyNetError::ModelIntegrityMismatch {
1683            expected: "locale-aware registry".to_string(),
1684            actual: "backend integrity mismatch".to_string(),
1685        },
1686        ModelError::InitFailed(reason) => SafetyNetError::ModelUnavailable { reason },
1687        ModelError::InferenceFailed(message) | ModelError::Internal(message) => {
1688            SafetyNetError::Runtime { message }
1689        }
1690    }
1691}
1692
1693fn redact_structured(
1694    pipeline: &Pipeline,
1695    session: &Session,
1696    fields: BTreeMap<String, Value>,
1697    document_kind: DocumentKind,
1698    locale_chain: &[crate::LocaleTag],
1699    dictionaries: &DictionaryBundle,
1700) -> Result<CleanDocument> {
1701    let mut clean = BTreeMap::new();
1702    for (key, value) in fields {
1703        let path = format!("$.{key}");
1704        clean.insert(
1705            key.clone(),
1706            redact_structured_value(
1707                pipeline,
1708                session,
1709                value,
1710                &key,
1711                &path,
1712                document_kind,
1713                locale_chain,
1714                dictionaries,
1715            )?,
1716        );
1717    }
1718    Ok(CleanDocument::Structured(clean))
1719}
1720
1721#[allow(clippy::too_many_arguments)]
1722fn redact_structured_value(
1723    pipeline: &Pipeline,
1724    session: &Session,
1725    value: Value,
1726    field_name: &str,
1727    field_path: &str,
1728    document_kind: DocumentKind,
1729    locale_chain: &[crate::LocaleTag],
1730    dictionaries: &DictionaryBundle,
1731) -> Result<Value> {
1732    match value {
1733        Value::String(text) => Ok(Value::String(pipeline.pseudonymize_text(
1734            session,
1735            &text,
1736            Some(field_name),
1737            document_kind,
1738            locale_chain,
1739            dictionaries,
1740        )?)),
1741        Value::Array(values) => values
1742            .into_iter()
1743            .enumerate()
1744            .map(|(idx, value)| {
1745                redact_structured_value(
1746                    pipeline,
1747                    session,
1748                    value,
1749                    field_name,
1750                    &format!("{field_path}[{idx}]"),
1751                    document_kind,
1752                    locale_chain,
1753                    dictionaries,
1754                )
1755            })
1756            .collect::<Result<Vec<_>>>()
1757            .map(Value::Array),
1758        Value::Object(fields) => {
1759            let mut clean = BTreeMap::new();
1760            for (key, value) in fields {
1761                let child_path = format!("{field_path}.{key}");
1762                clean.insert(
1763                    key.clone(),
1764                    redact_structured_value(
1765                        pipeline,
1766                        session,
1767                        value,
1768                        &key,
1769                        &child_path,
1770                        document_kind,
1771                        locale_chain,
1772                        dictionaries,
1773                    )?,
1774                );
1775            }
1776            Ok(Value::Object(clean))
1777        }
1778        Value::Null | Value::Bool(_) | Value::I64(_) => Ok(value),
1779        _ => Err(Error::UnsupportedValueVariant),
1780    }
1781}
1782
1783#[allow(clippy::too_many_arguments)]
1784fn redact_structured_with_safety_net(
1785    pipeline: &Pipeline,
1786    session: &Session,
1787    fields: BTreeMap<String, Value>,
1788    locale_chain: &[crate::LocaleTag],
1789    dictionaries: &DictionaryBundle,
1790    report: &mut LeakReport,
1791    policy: SafetyNetPolicy,
1792) -> Result<BTreeMap<String, Value>> {
1793    let mut clean = BTreeMap::new();
1794    for (key, value) in fields {
1795        let path = format!("$.{key}");
1796        clean.insert(
1797            key.clone(),
1798            redact_structured_value_with_safety_net(
1799                pipeline,
1800                session,
1801                value,
1802                &key,
1803                &path,
1804                locale_chain,
1805                dictionaries,
1806                report,
1807                policy,
1808            )?,
1809        );
1810    }
1811    Ok(clean)
1812}
1813
1814#[allow(clippy::too_many_arguments)]
1815fn redact_structured_value_with_safety_net(
1816    pipeline: &Pipeline,
1817    session: &Session,
1818    value: Value,
1819    field_name: &str,
1820    field_path: &str,
1821    locale_chain: &[crate::LocaleTag],
1822    dictionaries: &DictionaryBundle,
1823    report: &mut LeakReport,
1824    policy: SafetyNetPolicy,
1825) -> Result<Value> {
1826    match value {
1827        Value::String(text) => {
1828            if text.is_empty() {
1829                return Ok(Value::String(text));
1830            }
1831            let clean = pipeline.redact_text_with_manifest(
1832                session,
1833                &text,
1834                Some(field_name),
1835                DocumentKind::Structured,
1836                locale_chain,
1837                dictionaries,
1838            )?;
1839            // For RawDocument::Structured, locale gating uses the session-level
1840            // locale chain across all fields; fields have no locale annotations.
1841            let field_report = pipeline.run_safety_nets(
1842                session,
1843                &clean.text,
1844                &Manifest::from_spans(clean.manifest),
1845                DocumentKind::Structured,
1846                locale_chain,
1847                Some(field_path),
1848                policy.mode,
1849            )?;
1850            report.extend(field_report);
1851            Ok(Value::String(clean.text))
1852        }
1853        Value::Array(values) => values
1854            .into_iter()
1855            .enumerate()
1856            .map(|(idx, value)| {
1857                redact_structured_value_with_safety_net(
1858                    pipeline,
1859                    session,
1860                    value,
1861                    field_name,
1862                    &format!("{field_path}[{idx}]"),
1863                    locale_chain,
1864                    dictionaries,
1865                    report,
1866                    policy,
1867                )
1868            })
1869            .collect::<Result<Vec<_>>>()
1870            .map(Value::Array),
1871        Value::Object(fields) => {
1872            let mut clean = BTreeMap::new();
1873            for (key, value) in fields {
1874                let child_path = format!("{field_path}.{key}");
1875                clean.insert(
1876                    key.clone(),
1877                    redact_structured_value_with_safety_net(
1878                        pipeline,
1879                        session,
1880                        value,
1881                        &key,
1882                        &child_path,
1883                        locale_chain,
1884                        dictionaries,
1885                        report,
1886                        policy,
1887                    )?,
1888                );
1889            }
1890            Ok(Value::Object(clean))
1891        }
1892        Value::Null | Value::Bool(_) | Value::I64(_) => {
1893            if let Some(scalar) = value.scalar_to_safety_net_string() {
1894                let field_report = pipeline.run_safety_nets(
1895                    session,
1896                    &scalar,
1897                    &Manifest::default(),
1898                    DocumentKind::Structured,
1899                    locale_chain,
1900                    Some(field_path),
1901                    policy.mode,
1902                )?;
1903                report.extend(field_report);
1904            }
1905            Ok(value)
1906        }
1907        _ => Err(Error::UnsupportedValueVariant),
1908    }
1909}
1910
1911fn walk_value_for_safety_net_scan(
1912    pipeline: &Pipeline,
1913    session: &Session,
1914    value: &Value,
1915    field_path: &str,
1916    locale_chain: &[crate::LocaleTag],
1917    report: &mut LeakReport,
1918) -> Result<()> {
1919    match value {
1920        Value::String(text) => {
1921            if !text.is_empty() {
1922                let field_report = pipeline.run_safety_nets(
1923                    session,
1924                    text,
1925                    &Manifest::default(),
1926                    DocumentKind::Structured,
1927                    locale_chain,
1928                    Some(field_path),
1929                    SafetyNetMode::Strict,
1930                )?;
1931                report.extend(field_report);
1932            }
1933        }
1934        Value::Null => {}
1935        Value::Bool(_) | Value::I64(_) => {
1936            if let Some(scalar) = value.scalar_to_safety_net_string() {
1937                let field_report = pipeline.run_safety_nets(
1938                    session,
1939                    &scalar,
1940                    &Manifest::default(),
1941                    DocumentKind::Structured,
1942                    locale_chain,
1943                    Some(field_path),
1944                    SafetyNetMode::Strict,
1945                )?;
1946                report.extend(field_report);
1947            }
1948        }
1949        Value::Array(values) => {
1950            for (idx, value) in values.iter().enumerate() {
1951                walk_value_for_safety_net_scan(
1952                    pipeline,
1953                    session,
1954                    value,
1955                    &format!("{field_path}[{idx}]"),
1956                    locale_chain,
1957                    report,
1958                )?;
1959            }
1960        }
1961        Value::Object(fields) => {
1962            for (key, value) in fields {
1963                walk_value_for_safety_net_scan(
1964                    pipeline,
1965                    session,
1966                    value,
1967                    &format!("{field_path}.{key}"),
1968                    locale_chain,
1969                    report,
1970                )?;
1971            }
1972        }
1973        _ => return Err(Error::UnsupportedValueVariant),
1974    }
1975    Ok(())
1976}
1977
1978fn translate_candidate(candidate: Candidate, spans: &[(usize, usize)]) -> Option<Candidate> {
1979    translate_span(candidate.span.clone(), spans).map(|span| candidate.with_span(span))
1980}
1981
1982fn translate_vetoed_candidate(
1983    vetoed: crate::validator_veto::VetoedCandidate,
1984    spans: &[(usize, usize)],
1985) -> Option<crate::validator_veto::VetoedCandidate> {
1986    translate_candidate(vetoed.candidate, spans).map(|candidate| {
1987        crate::validator_veto::VetoedCandidate {
1988            candidate,
1989            reason: vetoed.reason,
1990        }
1991    })
1992}
1993
1994fn translate_span(
1995    span: std::ops::Range<usize>,
1996    spans: &[(usize, usize)],
1997) -> Option<std::ops::Range<usize>> {
1998    if span.is_empty() || span.end > spans.len() {
1999        return None;
2000    }
2001
2002    let start = spans[span.start].0;
2003    let end = spans[span.end - 1].1;
2004    Some(start..end)
2005}
2006
2007fn merged_losers(resolved: &[Candidate], registry: &RecognizerRegistry) -> Vec<IndexedDetection> {
2008    resolved
2009        .iter()
2010        .flat_map(|winner| {
2011            winner.merged_sources.iter().map(|source| {
2012                let class = registry
2013                    .recognizer(source)
2014                    .map(|recognizer| recognizer.supported_class().clone())
2015                    .unwrap_or_else(|| winner.class.clone());
2016                let membership = registry.family_policy().membership(source);
2017                IndexedDetection {
2018                    detection: Detection::new(winner.span.clone(), class, source.clone()),
2019                    recognizer_id: Some(source.clone()),
2020                    recognizer_version_id: None,
2021                    decided_by: if winner.decided_by == ConflictTier::Merged {
2022                        ConflictTier::Merged
2023                    } else {
2024                        winner.decided_by
2025                    },
2026                    family: winner.token_family.clone(),
2027                    ambiguity_record: None,
2028                    collision_family: membership.map(|membership| membership.family.clone()),
2029                    collision_variant: membership.map(|membership| membership.variant.clone()),
2030                }
2031            })
2032        })
2033        .collect()
2034}
2035
2036fn indexed_detection_from_candidate(
2037    candidate: Candidate,
2038    registry: &RecognizerRegistry,
2039) -> IndexedDetection {
2040    let membership = registry
2041        .family_policy()
2042        .membership(&candidate.recognizer_id);
2043    let mut collision_family = membership.map(|membership| membership.family.clone());
2044    let collision_variant = membership.map(|membership| membership.variant.clone());
2045    let mut ambiguity_record = None;
2046
2047    let hybrid_reason = match candidate.decided_by {
2048        ConflictTier::CollisionPolicy => Some(AmbiguityReason::PrecedenceTie),
2049        ConflictTier::AnchoredContext => Some(AmbiguityReason::NoAnchor),
2050        _ => None,
2051    };
2052    if let Some(reason) = hybrid_reason {
2053        if let Some(hybrid) = crate::conflict::hybrid_fallback::emit(&candidate, registry, reason) {
2054            ambiguity_record = Some(hybrid.ambiguity_record);
2055            collision_family = Some(hybrid.collision_family);
2056        }
2057    }
2058
2059    IndexedDetection {
2060        detection: Detection::new(candidate.span, candidate.class, candidate.source),
2061        recognizer_id: Some(candidate.recognizer_id),
2062        recognizer_version_id: candidate.recognizer_version_id,
2063        decided_by: candidate.decided_by,
2064        family: candidate.token_family,
2065        ambiguity_record,
2066        collision_family,
2067        collision_variant,
2068    }
2069}
2070
2071struct DetectorRecognizer<D> {
2072    detector: D,
2073    class: crate::PiiClass,
2074}
2075
2076impl<D> DetectorRecognizer<D> {
2077    fn new(detector: D) -> Self {
2078        Self {
2079            detector,
2080            class: crate::PiiClass::Custom("__legacy_detector__".to_string()),
2081        }
2082    }
2083}
2084
2085impl<D> Recognizer for DetectorRecognizer<D>
2086where
2087    D: Detector + Send + Sync + 'static,
2088{
2089    fn id(&self) -> &str {
2090        "legacy-detector"
2091    }
2092
2093    fn supported_class(&self) -> &crate::PiiClass {
2094        &self.class
2095    }
2096
2097    fn detect(
2098        &self,
2099        input: &str,
2100        _ctx: &DetectContext<'_>,
2101    ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2102        Ok(self
2103            .detector
2104            .try_detect(input)
2105            .map_err(|err| gaze_types::DetectError::backend(err.recognizer_id, err.message))?
2106            .into_iter()
2107            .map(|detection| {
2108                let source = detection.source;
2109                Candidate::new(
2110                    detection.span,
2111                    detection.class,
2112                    source.clone(),
2113                    1.0,
2114                    0,
2115                    None,
2116                    "counter",
2117                    source,
2118                    ConflictTier::None,
2119                    Vec::new(),
2120                )
2121            })
2122            .collect())
2123    }
2124
2125    fn token_family(&self) -> &str {
2126        "counter"
2127    }
2128}
2129
2130fn generalize_token(class: &PiiClass) -> String {
2131    match class {
2132        PiiClass::Email => "[EMAIL]".to_string(),
2133        PiiClass::Name => "[NAME]".to_string(),
2134        PiiClass::Location => "[LOCATION]".to_string(),
2135        PiiClass::Organization => "[ORGANIZATION]".to_string(),
2136        PiiClass::Custom(name) => format!("[{}]", name.to_ascii_uppercase()),
2137    }
2138}
2139
2140fn build_context(field_name: Option<&str>) -> RuleContext {
2141    RuleContext {
2142        field_name: field_name.map(str::to_string),
2143    }
2144}
2145
2146#[cfg(test)]
2147mod tests {
2148    use super::*;
2149    use crate::detector::{Detection, PiiClass};
2150    use crate::rule::{ClassRule, DefaultRule};
2151    use crate::session::{Scope, Session};
2152    use std::sync::atomic::{AtomicUsize, Ordering};
2153    use std::sync::Mutex;
2154
2155    /// Shared-handle test double: callers keep an `Arc<Mutex<Vec<_>>>` and
2156    /// clone it into the logger, letting the builder take ownership while
2157    /// the test retains read access.
2158    struct CapturingLogger {
2159        entries: Arc<Mutex<Vec<RedactionEntry>>>,
2160    }
2161
2162    struct FixedDetector {
2163        detections: Vec<Detection>,
2164    }
2165
2166    impl Detector for FixedDetector {
2167        fn detect(&self, _input: &str) -> Vec<Detection> {
2168            self.detections.clone()
2169        }
2170    }
2171
2172    struct FailingRecognizer;
2173
2174    impl Recognizer for FailingRecognizer {
2175        fn id(&self) -> &str {
2176            "ner"
2177        }
2178
2179        fn supported_class(&self) -> &PiiClass {
2180            &PiiClass::Name
2181        }
2182
2183        fn detect(
2184            &self,
2185            _input: &str,
2186            _ctx: &DetectContext<'_>,
2187        ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2188            Err(gaze_types::DetectError::backend(
2189                self.id(),
2190                "synthetic backend failure",
2191            ))
2192        }
2193
2194        fn token_family(&self) -> &str {
2195            "counter"
2196        }
2197    }
2198
2199    fn detector_with_detections(source: &str, detections: Vec<Detection>) -> FixedDetector {
2200        FixedDetector {
2201            detections: detections
2202                .into_iter()
2203                .map(|mut detection| {
2204                    detection.source = source.to_string();
2205                    detection
2206                })
2207                .collect(),
2208        }
2209    }
2210
2211    #[test]
2212    fn recognizer_backend_failure_fails_closed_before_output() {
2213        let pipeline = Pipeline::builder()
2214            .recognizer(FailingRecognizer)
2215            .build()
2216            .expect("pipeline");
2217        let session =
2218            Session::new(Scope::Conversation("detect-failclosed".to_string())).expect("session");
2219
2220        let err = pipeline
2221            .redact(&session, RawDocument::Text("Hello Dr. Schmidt".to_string()))
2222            .expect_err("recognizer backend failure must abort redaction");
2223
2224        assert!(matches!(
2225            err,
2226            Error::RecognizerDetect(gaze_types::DetectError::Backend {
2227                recognizer_id,
2228                ..
2229            }) if recognizer_id == "ner"
2230        ));
2231    }
2232
2233    impl RedactionLogger for CapturingLogger {
2234        fn log(&self, entry: &RedactionEntry) -> std::result::Result<(), RedactionLogError> {
2235            self.entries.lock().unwrap().push(entry.clone());
2236            Ok(())
2237        }
2238    }
2239
2240    #[test]
2241    fn restore_with_telemetry_counts_unknown_tokens_deterministically() {
2242        let pipeline = Pipeline::builder().build().expect("pipeline");
2243        let session =
2244            Session::new(Scope::Conversation("restore-telemetry".to_string())).expect("session");
2245        let clean = pipeline
2246            .redact(
2247                &session,
2248                RawDocument::Text("Reach alice@example.invalid".to_string()),
2249            )
2250            .expect("redact");
2251        let CleanDocument::Text(clean) = clean else {
2252            panic!("expected text");
2253        };
2254        let input = format!("{clean} <Email_999> <Name_100>");
2255
2256        let (restored, telemetry) = pipeline
2257            .restore_with_policy_telemetry(&session, &input, RestorePolicy::Lenient)
2258            .expect("restore telemetry");
2259        let (_, telemetry_again) = pipeline
2260            .restore_with_policy_telemetry(&session, &input, RestorePolicy::Lenient)
2261            .expect("restore telemetry");
2262
2263        assert!(restored.text.contains("alice@example.invalid"));
2264        assert_eq!(telemetry.unknown_token_count, 2);
2265        assert_eq!(telemetry.manifest_bypass_count, 2);
2266        assert_eq!(telemetry.fresh_pii_detected_count, 0);
2267        assert_eq!(telemetry.restore_policy, RestorePolicy::Lenient);
2268        assert_eq!(telemetry.restore_decision, RestoreDecision::Partial);
2269        assert_eq!(
2270            telemetry.phase_execution_mask,
2271            RESTORE_PHASE_MANIFEST_LOOKUP
2272                | RESTORE_PHASE_UNKNOWN_TOKEN_SCAN
2273                | RESTORE_PHASE_MANIFEST_BYPASS_SCAN
2274        );
2275        assert_eq!(telemetry_again, telemetry);
2276    }
2277
2278    struct CountingSafetyNet {
2279        calls: Arc<AtomicUsize>,
2280    }
2281
2282    impl SafetyNet for CountingSafetyNet {
2283        fn id(&self) -> &str {
2284            "counting"
2285        }
2286        fn supported_locales(&self) -> &[crate::LocaleTag] {
2287            &[crate::LocaleTag::Global]
2288        }
2289        fn check(
2290            &self,
2291            _clean_text: &str,
2292            _context: SafetyNetContext<'_>,
2293        ) -> std::result::Result<Vec<LeakSuspect>, SafetyNetError> {
2294            self.calls.fetch_add(1, Ordering::SeqCst);
2295            Ok(Vec::new())
2296        }
2297    }
2298
2299    #[test]
2300    fn generalize_token_custom_class_preserves_identity() {
2301        // Regression guard: custom classes must not collapse to an indistinct [PII].
2302        assert_eq!(generalize_token(&PiiClass::Custom("foo".into())), "[FOO]");
2303    }
2304
2305    #[test]
2306    fn skip_class_gating_skips_only_observer_mode() {
2307        let calls = Arc::new(AtomicUsize::new(0));
2308        let pipeline = Pipeline::builder()
2309            .detector(detector_with_detections(
2310                "email.global",
2311                vec![Detection::new(6..27, PiiClass::Email, "email.global")],
2312            ))
2313            .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
2314            .rule(DefaultRule::new(Action::Preserve))
2315            .register_safety_net(CountingSafetyNet {
2316                calls: Arc::clone(&calls),
2317            })
2318            .enable_skip_class_gating()
2319            .build()
2320            .expect("pipeline");
2321        let session = Session::new(Scope::Ephemeral).expect("session");
2322        let dictionaries = DictionaryBundle::default();
2323        pipeline
2324            .clean_with_safety_net_policy_detect_context(
2325                &session,
2326                RawDocument::Text("Reach alice@example.invalid".to_string()),
2327                &[crate::LocaleTag::Global],
2328                &dictionaries,
2329                SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
2330            )
2331            .expect("observer safety net");
2332        assert_eq!(calls.load(Ordering::SeqCst), 0);
2333        pipeline
2334            .clean_with_safety_net_policy_detect_context(
2335                &session,
2336                RawDocument::Text("Reach alice@example.invalid".to_string()),
2337                &[crate::LocaleTag::Global],
2338                &dictionaries,
2339                SafetyNetPolicy::new(SafetyNetMode::Resolve, SafetyNetFallback::Redact),
2340            )
2341            .expect("resolve safety net");
2342        assert!(calls.load(Ordering::SeqCst) > 0);
2343    }
2344
2345    #[test]
2346    fn capitals_gate_fails_closed_for_unsupported_locale() {
2347        let calls = Arc::new(AtomicUsize::new(0));
2348        let pipeline = Pipeline::builder()
2349            .register_safety_net(CountingSafetyNet {
2350                calls: Arc::clone(&calls),
2351            })
2352            .enable_capitals_heuristic_gate()
2353            .build()
2354            .expect("pipeline");
2355        let session = Session::new(Scope::Ephemeral).expect("session");
2356        let dictionaries = DictionaryBundle::default();
2357        let err = pipeline
2358            .clean_with_safety_net_policy_detect_context(
2359                &session,
2360                RawDocument::Text("مرحبا".to_string()),
2361                &[crate::LocaleTag::Other("ar-SA".to_string())],
2362                &dictionaries,
2363                SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
2364            )
2365            .expect_err("unsupported capitals locale must fail closed");
2366        assert!(matches!(
2367            err,
2368            Error::UnsupportedCapitalHeuristicLocale { locale } if locale == "ar-SA"
2369        ));
2370        assert_eq!(calls.load(Ordering::SeqCst), 0);
2371    }
2372
2373    #[test]
2374    fn prefix_cache_hits_emit_audit_provenance() {
2375        struct NameRecognizer;
2376        impl Recognizer for NameRecognizer {
2377            fn id(&self) -> &str {
2378                "name.fixture"
2379            }
2380            fn supported_class(&self) -> &PiiClass {
2381                &PiiClass::Name
2382            }
2383            fn detect(
2384                &self,
2385                input: &str,
2386                _ctx: &DetectContext<'_>,
2387            ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2388                Ok(input
2389                    .find("Dr. Schmidt")
2390                    .map(|start| {
2391                        Candidate::new(
2392                            start..start + "Dr. Schmidt".len(),
2393                            PiiClass::Name,
2394                            self.id(),
2395                            1.0,
2396                            0,
2397                            None,
2398                            self.token_family(),
2399                            self.id(),
2400                            ConflictTier::None,
2401                            Vec::new(),
2402                        )
2403                    })
2404                    .into_iter()
2405                    .collect())
2406            }
2407            fn token_family(&self) -> &str {
2408                "counter"
2409            }
2410        }
2411        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2412        let pipeline = Pipeline::builder()
2413            .recognizer(NameRecognizer)
2414            .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2415            .rule(DefaultRule::new(Action::Preserve))
2416            .redaction_logger(CapturingLogger {
2417                entries: Arc::clone(&entries),
2418            })
2419            .enable_prefix_cache()
2420            .build()
2421            .expect("pipeline");
2422        let session = Session::new(Scope::Ephemeral).expect("session");
2423        pipeline
2424            .redact(&session, RawDocument::Text("Dr. Schmidt".to_string()))
2425            .expect("prime cache");
2426        pipeline
2427            .redact(
2428                &session,
2429                RawDocument::Text("Dr. Schmidt reports".to_string()),
2430            )
2431            .expect("cache hit");
2432        let entries = entries.lock().unwrap();
2433        assert!(entries.iter().any(|entry| {
2434            entry.source == "prefix_cache"
2435                && entry.provenance_stage.as_deref() == Some("prefix_cache")
2436        }));
2437    }
2438
2439    #[test]
2440    fn restore_strict_rejection_emits_audit_provenance() {
2441        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2442        let pipeline = Pipeline::builder()
2443            .redaction_logger(CapturingLogger {
2444                entries: Arc::clone(&entries),
2445            })
2446            .build()
2447            .expect("pipeline");
2448        let session = Session::new(Scope::Ephemeral).expect("session");
2449
2450        let err = pipeline
2451            .restore_strict_text(&session, "Reply to <deadbeef:Email_1>.")
2452            .expect_err("unknown token must fail closed");
2453
2454        assert!(matches!(
2455            err,
2456            Error::UnknownToken {
2457                class: PiiClass::Email,
2458                ordinal: 1,
2459                raw,
2460            } if raw == "<deadbeef:Email_1>"
2461        ));
2462        let entries = entries.lock().unwrap();
2463        assert_eq!(entries.len(), 1);
2464        assert_eq!(entries[0].source, "restore_strict");
2465        assert_eq!(entries[0].class, PiiClass::Email);
2466        assert_eq!(entries[0].action, Action::Preserve);
2467        assert_eq!(
2468            entries[0].provenance_stage.as_deref(),
2469            Some("restore_strict")
2470        );
2471    }
2472
2473    #[test]
2474    fn restore_boundary_dlp_emits_metadata_only_audit_rows() {
2475        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2476        let pipeline = Pipeline::builder()
2477            .redaction_logger(CapturingLogger {
2478                entries: Arc::clone(&entries),
2479            })
2480            .enable_restore_boundary_dlp_audit()
2481            .build()
2482            .expect("pipeline");
2483        let session = Session::new(Scope::Ephemeral).expect("session");
2484        let token = session
2485            .tokenize(&PiiClass::Email, "alice@example.invalid")
2486            .expect("token");
2487
2488        let restored = pipeline
2489            .restore_strict_text(
2490                &session,
2491                &format!("{token} raw alice@example.invalid fresh bob@example.invalid"),
2492            )
2493            .expect("restore remains audit-only");
2494
2495        assert!(restored.contains("alice@example.invalid"));
2496        let entries = entries.lock().unwrap();
2497        let dlp_entries = entries
2498            .iter()
2499            .filter(|entry| entry.provenance_stage.as_deref() == Some("restore_boundary_dlp"))
2500            .collect::<Vec<_>>();
2501        assert_eq!(dlp_entries.len(), 2);
2502        assert!(dlp_entries
2503            .iter()
2504            .any(|entry| entry.source == "manifest_bypass"));
2505        assert!(dlp_entries
2506            .iter()
2507            .any(|entry| entry.source == "fresh_pii_detected"));
2508        let serialized = serde_json::to_string(&dlp_entries).expect("audit json");
2509        assert!(!serialized.contains("alice@example.invalid"));
2510        assert!(!serialized.contains("bob@example.invalid"));
2511        assert!(serialized.contains("sha256:"));
2512    }
2513
2514    #[test]
2515    fn restore_boundary_dlp_audit_is_default_off() {
2516        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2517        let pipeline = Pipeline::builder()
2518            .redaction_logger(CapturingLogger {
2519                entries: Arc::clone(&entries),
2520            })
2521            .build()
2522            .expect("pipeline");
2523        let session = Session::new(Scope::Ephemeral).expect("session");
2524
2525        pipeline
2526            .restore_strict_text(&session, "fresh bob@example.invalid")
2527            .expect("restore remains audit-only");
2528
2529        assert!(entries.lock().unwrap().is_empty());
2530    }
2531
2532    #[test]
2533    fn stacked_ner_detectors_resolve_via_span_conflict() {
2534        // Input: "Alice Smith works here" — byte spans: Alice=0..5, full name=0..11.
2535        let text = "Alice Smith works here";
2536        let short_detection = Detection::new(0..5, PiiClass::Name, "ner/bert");
2537        let long_detection = Detection::new(0..11, PiiClass::Name, "ner/gliner");
2538
2539        let bert = detector_with_detections("ner/bert", vec![short_detection]);
2540        let gliner = detector_with_detections("ner/gliner", vec![long_detection]);
2541
2542        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2543
2544        let pipeline = Pipeline::builder()
2545            .detector(bert)
2546            .detector(gliner)
2547            .rule(ClassRule::new(PiiClass::Name, Action::Redact))
2548            .rule(DefaultRule::new(Action::Preserve))
2549            .redaction_logger(CapturingLogger {
2550                entries: Arc::clone(&entries),
2551            })
2552            .build()
2553            .expect("pipeline");
2554
2555        let session = Session::new(Scope::Ephemeral).expect("session");
2556        let clean = pipeline
2557            .redact(&session, RawDocument::Text(text.to_string()))
2558            .expect("redact");
2559
2560        let out = match clean {
2561            CleanDocument::Text(t) => t,
2562            _ => panic!("expected text"),
2563        };
2564
2565        // Longer span wins: full name replaced, trailing " works here" preserved.
2566        assert_eq!(out, "[REDACTED] works here");
2567
2568        let entries = entries.lock().unwrap();
2569        assert_eq!(
2570            entries.len(),
2571            2,
2572            "expected one winner + one loser: {entries:?}"
2573        );
2574        let winner = entries.iter().find(|e| !e.conflict_loser).expect("winner");
2575        let loser = entries.iter().find(|e| e.conflict_loser).expect("loser");
2576        assert_eq!(winner.source, "ner/gliner", "longer span should win");
2577        assert_eq!(loser.source, "ner/bert", "shorter span should lose");
2578        assert_eq!(loser.decided_by, ConflictTier::SpanLength);
2579    }
2580
2581    #[test]
2582    fn stacked_detectors_both_win_when_spans_disjoint() {
2583        let text = "Alice visited Berlin";
2584        let alice = Detection::new(0..5, PiiClass::Name, "ner/bert");
2585        let berlin = Detection::new(14..20, PiiClass::Location, "ner/gliner");
2586
2587        let bert = detector_with_detections("ner/bert", vec![alice]);
2588        let gliner = detector_with_detections("ner/gliner", vec![berlin]);
2589
2590        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2591
2592        let pipeline = Pipeline::builder()
2593            .detector(bert)
2594            .detector(gliner)
2595            .rule(ClassRule::new(PiiClass::Name, Action::Redact))
2596            .rule(ClassRule::new(PiiClass::Location, Action::Redact))
2597            .rule(DefaultRule::new(Action::Preserve))
2598            .redaction_logger(CapturingLogger {
2599                entries: Arc::clone(&entries),
2600            })
2601            .build()
2602            .expect("pipeline");
2603
2604        let session = Session::new(Scope::Ephemeral).expect("session");
2605        let clean = pipeline
2606            .redact(&session, RawDocument::Text(text.to_string()))
2607            .expect("redact");
2608
2609        let out = match clean {
2610            CleanDocument::Text(t) => t,
2611            _ => panic!("expected text"),
2612        };
2613
2614        assert_eq!(out, "[REDACTED] visited [REDACTED]");
2615        let entries = entries.lock().unwrap();
2616        assert_eq!(entries.len(), 2);
2617        assert!(entries.iter().all(|e| !e.conflict_loser));
2618    }
2619
2620    #[test]
2621    fn audit_entry_threads_recognizer_lineage_from_candidate() {
2622        struct VersionedRecognizer;
2623
2624        impl Recognizer for VersionedRecognizer {
2625            fn id(&self) -> &str {
2626                "name.semantic"
2627            }
2628
2629            fn supported_class(&self) -> &PiiClass {
2630                &PiiClass::Name
2631            }
2632
2633            fn detect(
2634                &self,
2635                input: &str,
2636                _ctx: &DetectContext<'_>,
2637            ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2638                let Some(start) = input.find("Dr. Schmidt") else {
2639                    return Ok(Vec::new());
2640                };
2641                let end = start + "Dr. Schmidt".len();
2642                Ok(vec![Candidate::new(
2643                    start..end,
2644                    PiiClass::Name,
2645                    self.id(),
2646                    1.0,
2647                    0,
2648                    None,
2649                    self.token_family(),
2650                    "ner/ort",
2651                    ConflictTier::None,
2652                    Vec::new(),
2653                )
2654                .with_recognizer_version_id("name.semantic.v2")])
2655            }
2656
2657            fn token_family(&self) -> &str {
2658                "counter"
2659            }
2660        }
2661
2662        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2663        let pipeline = Pipeline::builder()
2664            .recognizer(VersionedRecognizer)
2665            .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2666            .rule(DefaultRule::new(Action::Preserve))
2667            .redaction_logger(CapturingLogger {
2668                entries: Arc::clone(&entries),
2669            })
2670            .build()
2671            .expect("pipeline");
2672        let session = Session::new(Scope::Ephemeral).expect("session");
2673
2674        pipeline
2675            .redact(
2676                &session,
2677                RawDocument::Text("Contact Dr. Schmidt today.".to_string()),
2678            )
2679            .expect("redact");
2680
2681        let entries = entries.lock().unwrap();
2682        assert_eq!(entries.len(), 1);
2683        assert_eq!(entries[0].source, "ner/ort");
2684        assert_eq!(entries[0].recognizer_id.as_deref(), Some("name.semantic"));
2685        assert_eq!(
2686            entries[0].recognizer_version_id.as_deref(),
2687            Some("name.semantic.v2")
2688        );
2689    }
2690
2691    #[cfg(feature = "bundled-recognizers")]
2692    #[test]
2693    fn locale_safety_net_registry_logs_dropped_backends() {
2694        struct EmptyLocaleModel {
2695            name: &'static str,
2696            locales: Vec<crate::LocaleTag>,
2697        }
2698
2699        impl gaze_recognizers::LocaleAwareModel for EmptyLocaleModel {
2700            fn name(&self) -> &str {
2701                self.name
2702            }
2703
2704            fn native_locales(&self) -> &[crate::LocaleTag] {
2705                &self.locales
2706            }
2707
2708            fn infer(
2709                &self,
2710                _input: ModelInput,
2711                _hints: ModelHints,
2712            ) -> std::result::Result<Vec<ModelSpan>, ModelError> {
2713                Ok(Vec::new())
2714            }
2715        }
2716
2717        let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2718        let registry = LocaleAwareModelRegistry::from_backends(vec![
2719            Box::new(EmptyLocaleModel {
2720                name: "opf-primary",
2721                locales: vec![crate::LocaleTag::EnUs],
2722            }),
2723            Box::new(EmptyLocaleModel {
2724                name: "opf-shadow",
2725                locales: vec![crate::LocaleTag::EnUs],
2726            }),
2727        ]);
2728        let pipeline = Pipeline::builder()
2729            .rule(DefaultRule::new(Action::Preserve))
2730            .redaction_logger(CapturingLogger {
2731                entries: Arc::clone(&entries),
2732            })
2733            .register_safety_net_registry(registry)
2734            .build()
2735            .expect("pipeline");
2736        let session = Session::new(Scope::Ephemeral).expect("session");
2737
2738        pipeline
2739            .scan_safety_nets(&session, "plain text", &[crate::LocaleTag::EnUs])
2740            .expect("safety net scan");
2741
2742        let entries = entries.lock().unwrap();
2743        assert_eq!(entries.len(), 1);
2744        assert_eq!(
2745            entries[0].backend_silently_dropped.as_deref(),
2746            Some(&["opf-shadow".to_string()][..])
2747        );
2748        assert_eq!(entries[0].source, "safety_net.opf-primary");
2749    }
2750
2751    #[test]
2752    fn pipeline_builder_detects_email() {
2753        struct EmailDetector(regex::Regex);
2754
2755        impl Detector for EmailDetector {
2756            fn detect(&self, input: &str) -> Vec<Detection> {
2757                self.0
2758                    .find_iter(input)
2759                    .map(|m| Detection::new(m.range(), PiiClass::Email, "regex"))
2760                    .collect()
2761            }
2762        }
2763
2764        let pipeline = Pipeline::builder()
2765            .detector(EmailDetector(
2766                regex::Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap(),
2767            ))
2768            .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
2769            .rule(DefaultRule::new(Action::Preserve))
2770            .build()
2771            .unwrap();
2772        let session = Session::new(Scope::Ephemeral).unwrap();
2773
2774        let clean = pipeline
2775            .redact(
2776                &session,
2777                RawDocument::Text("Reach alice@example.invalid today".to_string()),
2778            )
2779            .unwrap();
2780
2781        match clean {
2782            CleanDocument::Text(text) => {
2783                assert!(text.starts_with("Reach <"));
2784                assert!(text.ends_with(":Email_1> today"));
2785            }
2786            other => panic!("expected text output, got {other:?}"),
2787        }
2788    }
2789
2790    #[test]
2791    fn t21d_token_family_threads_from_recognizer_to_session() {
2792        struct FamilyRecognizer;
2793
2794        impl Recognizer for FamilyRecognizer {
2795            fn id(&self) -> &str {
2796                "name.alpha"
2797            }
2798
2799            fn supported_class(&self) -> &PiiClass {
2800                &PiiClass::Name
2801            }
2802
2803            fn detect(
2804                &self,
2805                input: &str,
2806                _ctx: &DetectContext<'_>,
2807            ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2808                let Some(start) = input.find("Dr. Schmidt") else {
2809                    return Ok(Vec::new());
2810                };
2811                let end = start + "Dr. Schmidt".len();
2812                Ok(vec![Candidate::new(
2813                    start..end,
2814                    PiiClass::Name,
2815                    self.id(),
2816                    1.0,
2817                    0,
2818                    None,
2819                    self.token_family(),
2820                    self.id(),
2821                    ConflictTier::None,
2822                    Vec::new(),
2823                )])
2824            }
2825
2826            fn token_family(&self) -> &str {
2827                "alpha"
2828            }
2829        }
2830
2831        let pipeline = Pipeline::builder()
2832            .recognizer(FamilyRecognizer)
2833            .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2834            .rule(DefaultRule::new(Action::Preserve))
2835            .build()
2836            .expect("pipeline");
2837        let session = Session::new(Scope::Ephemeral).expect("session");
2838
2839        let clean = pipeline
2840            .redact(
2841                &session,
2842                RawDocument::Text("Assigned to Dr. Schmidt".to_string()),
2843            )
2844            .expect("redact");
2845        let CleanDocument::Text(text) = clean else {
2846            panic!("expected text");
2847        };
2848        let token = text
2849            .strip_prefix("Assigned to ")
2850            .expect("token prefix")
2851            .to_string();
2852        assert!(regex::Regex::new(r"^<[0-9a-f]{8}:Name_[0-9]+>$")
2853            .unwrap()
2854            .is_match(&token));
2855
2856        let beta = session
2857            .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
2858            .expect("beta token");
2859        assert_ne!(token, beta);
2860        assert_eq!(
2861            session
2862                .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
2863                .expect("alpha token"),
2864            token
2865        );
2866        assert_eq!(session.restore(&token).as_deref(), Some("Dr. Schmidt"));
2867        assert_eq!(session.restore(&beta).as_deref(), Some("Dr. Schmidt"));
2868    }
2869}