Skip to main content

gaze_types/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3use std::cell::Cell;
4use std::collections::{BTreeMap, HashMap};
5use std::fmt;
6use std::ops::Range;
7
8use serde::{Deserialize, Serialize};
9use sha3::{Digest, Keccak256};
10use thiserror::Error;
11
12/// Shared detector contract for text-only PII detection.
13pub trait Detector: Send + Sync {
14    /// Detect PII spans in the supplied input string.
15    fn detect(&self, input: &str) -> Vec<Detection>;
16
17    /// Fallible detection entrypoint for detectors backed by runtime systems.
18    fn try_detect(&self, input: &str) -> Result<Vec<Detection>, RecognizerRuntimeError> {
19        Ok(self.detect(input))
20    }
21}
22
23/// Runtime failure raised by a recognizer or detector backend during detection.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct RecognizerRuntimeError {
27    pub recognizer_id: String,
28    pub message: String,
29}
30
31impl RecognizerRuntimeError {
32    pub fn new(recognizer_id: impl Into<String>, message: impl Into<String>) -> Self {
33        Self {
34            recognizer_id: recognizer_id.into(),
35            message: message.into(),
36        }
37    }
38}
39
40impl fmt::Display for RecognizerRuntimeError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(
43            f,
44            "recognizer '{}' backend failed: {}",
45            self.recognizer_id, self.message
46        )
47    }
48}
49
50impl std::error::Error for RecognizerRuntimeError {}
51
52/// The category of a detected PII span.
53///
54/// Built-in variants: `Email`, `Name`, `Location`, `Organization`. Tenant-specific PII
55/// (case references, titles, internal codes) is carried as `PiiClass::Custom(String)`.
56/// **There is no `Phone` variant** -- phone detection is provided by recognizers in
57/// `gaze-recognizers` and surfaces as either a `Custom("phone")` class or a class
58/// defined by a rulepack.
59///
60/// `PiiClass` is exhaustive. Match every variant explicitly so new built-in classes
61/// force call sites to review their handling at compile time:
62///
63/// ```rust
64/// use gaze_types::PiiClass;
65///
66/// fn label(class: &PiiClass) -> &'static str {
67///     match class {
68///         PiiClass::Email        => "email",
69///         PiiClass::Name         => "name",
70///         PiiClass::Location     => "location",
71///         PiiClass::Organization => "org",
72///         PiiClass::Custom(_)    => "pii",
73///     }
74/// }
75/// ```
76///
77/// Policy TOML uses the lowercase forms `email` / `name` / `location` / `organization`,
78/// and tenant classes are spelled like `custom:case_ref` (lowercase, snake_case).
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub enum PiiClass {
81    /// Email address class.
82    Email,
83    /// Person name class.
84    Name,
85    /// Location class.
86    Location,
87    /// Organization class.
88    Organization,
89    /// Tenant- or policy-defined class.
90    Custom(String),
91}
92
93/// Built-in class labels in stable display order.
94pub const BUILTIN_CLASS_NAMES: &[&str] = &["Email", "Name", "Location", "Organization"];
95
96/// Family names reserved for bundled collision-policy rulepacks.
97///
98/// Adopter policy-level custom recognizers cannot claim these names because bundled
99/// families are part of the core disambiguation contract.
100pub const RESERVED_BUNDLED_FAMILIES: &[&str] = &[
101    "us-9-digit-id",
102    "iberian-id",
103    "payment-card-or-iban",
104    "phone-or-imei",
105    "vin-or-serial",
106    "mac-or-hex",
107    "passport-or-doc-support",
108    "national-13-digit",
109    "italian-cf-or-serial",
110    "german-personalausweis",
111    "swedish-personnummer",
112    "finnish-hetu",
113];
114
115pub const RESTORE_PHASE_MANIFEST_LOOKUP: u32 = 1 << 0;
116pub const RESTORE_PHASE_UNKNOWN_TOKEN_SCAN: u32 = 1 << 1;
117pub const RESTORE_PHASE_MANIFEST_BYPASS_SCAN: u32 = 1 << 2;
118pub const RESTORE_PHASE_FRESH_PII_SCAN: u32 = 1 << 3;
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[non_exhaustive]
122pub struct RestoredText {
123    pub text: String,
124}
125
126impl RestoredText {
127    pub fn new(text: impl Into<String>) -> Self {
128        Self { text: text.into() }
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum RestorePolicy {
136    Strict,
137    Lenient,
138}
139
140impl RestorePolicy {
141    pub fn as_str(self) -> &'static str {
142        match self {
143            Self::Strict => "strict",
144            Self::Lenient => "lenient",
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum RestoreDecision {
153    Success,
154    Partial,
155    Failed,
156}
157
158impl RestoreDecision {
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::Success => "success",
162            Self::Partial => "partial",
163            Self::Failed => "failed",
164        }
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[non_exhaustive]
170pub struct RestoreTelemetry {
171    pub unknown_token_count: u64,
172    pub manifest_bypass_count: u64,
173    pub fresh_pii_detected_count: u64,
174    pub restore_policy: RestorePolicy,
175    pub restore_decision: RestoreDecision,
176    pub phase_execution_mask: u32,
177}
178
179impl RestoreTelemetry {
180    pub fn new(restore_policy: RestorePolicy) -> Self {
181        Self {
182            unknown_token_count: 0,
183            manifest_bypass_count: 0,
184            fresh_pii_detected_count: 0,
185            restore_policy,
186            restore_decision: RestoreDecision::Success,
187            phase_execution_mask: 0,
188        }
189    }
190
191    pub fn restore_policy_str(&self) -> &'static str {
192        self.restore_policy.as_str()
193    }
194
195    pub fn restore_decision_str(&self) -> &'static str {
196        self.restore_decision.as_str()
197    }
198}
199
200/// Collision-family membership metadata for one recognizer.
201#[derive(Debug, Clone, PartialEq, Eq)]
202#[non_exhaustive]
203pub struct CollisionMembership {
204    /// Cross-class family name.
205    pub family: String,
206    /// Variant name within the family.
207    pub variant: String,
208    /// Lower values win when two variants in the same family overlap.
209    pub precedence: u32,
210    /// Optional anchor variant required by later ambiguity handling.
211    pub mandatory_anchor: Option<String>,
212}
213
214impl CollisionMembership {
215    /// Builds collision-family membership metadata.
216    pub fn new(
217        family: impl Into<String>,
218        variant: impl Into<String>,
219        precedence: u32,
220        mandatory_anchor: Option<String>,
221    ) -> Self {
222        Self {
223            family: family.into(),
224            variant: variant.into(),
225            precedence,
226            mandatory_anchor,
227        }
228    }
229}
230
231impl PiiClass {
232    /// Parses a policy class name into the shared class vocabulary.
233    pub fn from_policy_name(input: &str) -> Option<Self> {
234        match input {
235            "email" => Some(Self::Email),
236            "name" => Some(Self::Name),
237            "location" => Some(Self::Location),
238            "organization" => Some(Self::Organization),
239            custom if custom.starts_with("custom:") => {
240                let name = custom.trim_start_matches("custom:");
241                (!name.trim().is_empty()).then(|| Self::custom(name))
242            }
243            _ => None,
244        }
245    }
246
247    /// Returns the built-in class variants.
248    pub fn builtin_variants() -> &'static [PiiClass] {
249        &[
250            PiiClass::Email,
251            PiiClass::Name,
252            PiiClass::Location,
253            PiiClass::Organization,
254        ]
255    }
256
257    /// Builds a normalized custom class name.
258    pub fn custom(name: &str) -> Self {
259        let mut normalized = String::new();
260        let mut pending_underscore = false;
261        for ch in name.trim().chars() {
262            if ch.is_ascii_alphanumeric() {
263                if pending_underscore && !normalized.is_empty() {
264                    normalized.push('_');
265                }
266                normalized.push(ch.to_ascii_lowercase());
267                pending_underscore = false;
268            } else {
269                pending_underscore = true;
270            }
271        }
272
273        Self::Custom(normalized)
274    }
275
276    /// Returns the normalized custom class name for custom classes.
277    pub fn as_custom_name(&self) -> Option<&str> {
278        match self {
279            Self::Custom(name) => Some(name.as_str()),
280            Self::Email | Self::Name | Self::Location | Self::Organization => None,
281        }
282    }
283
284    /// Returns the audit/token display label for this class.
285    pub fn class_name(&self) -> String {
286        match self {
287            Self::Email => BUILTIN_CLASS_NAMES[0].to_string(),
288            Self::Name => BUILTIN_CLASS_NAMES[1].to_string(),
289            Self::Location => BUILTIN_CLASS_NAMES[2].to_string(),
290            Self::Organization => BUILTIN_CLASS_NAMES[3].to_string(),
291            Self::Custom(name) => format!("Custom:{name}"),
292        }
293    }
294
295    /// Returns the canonical audit/serde label for this class.
296    pub fn to_canonical_str(&self) -> String {
297        match self {
298            Self::Email => "email".to_string(),
299            Self::Name => "name".to_string(),
300            Self::Location => "location".to_string(),
301            Self::Organization => "organization".to_string(),
302            Self::Custom(name) => format!("custom:{name}"),
303        }
304    }
305
306    /// Parses the canonical audit/serde label for a PII class.
307    pub fn from_canonical_str(value: &str) -> Option<Self> {
308        match value {
309            "email" | "Email" => Some(Self::Email),
310            "name" | "Name" => Some(Self::Name),
311            "location" | "Location" => Some(Self::Location),
312            "organization" | "Organization" => Some(Self::Organization),
313            custom if custom.starts_with("custom:") => {
314                let name = &custom["custom:".len()..];
315                (!name.is_empty()).then(|| Self::Custom(name.to_string()))
316            }
317            _ => None,
318        }
319    }
320}
321
322/// Audit-canonical form of [`PiiClass`].
323///
324/// Serializes as `"email"`, `"name"`, `"custom:foo"`, and similar canonical
325/// strings. Use this wrapper for audit-row JSON only. Session snapshots use
326/// bare [`PiiClass`] serde so their byte shape stays stable.
327#[derive(Debug, Clone, PartialEq, Eq)]
328#[non_exhaustive]
329pub struct PiiClassAudit(pub PiiClass);
330
331impl PiiClassAudit {
332    /// Builds an audit-canonical class wrapper.
333    pub fn new(class: PiiClass) -> Self {
334        Self(class)
335    }
336
337    /// Unwraps the underlying class.
338    pub fn into_inner(self) -> PiiClass {
339        self.0
340    }
341}
342
343impl Serialize for PiiClassAudit {
344    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
345    where
346        S: serde::Serializer,
347    {
348        serializer.serialize_str(&self.0.to_canonical_str())
349    }
350}
351
352impl<'de> Deserialize<'de> for PiiClassAudit {
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: serde::Deserializer<'de>,
356    {
357        let value = String::deserialize(deserializer)?;
358        PiiClass::from_canonical_str(&value)
359            .map(Self)
360            .ok_or_else(|| {
361                serde::de::Error::custom(format!("unknown PiiClass canonical form: {value}"))
362            })
363    }
364}
365
366mod pii_class_audit_serde {
367    use super::{PiiClass, PiiClassAudit};
368    use serde::{Deserialize, Deserializer, Serialize, Serializer};
369
370    pub fn serialize<S>(class: &PiiClass, serializer: S) -> Result<S::Ok, S::Error>
371    where
372        S: Serializer,
373    {
374        PiiClassAudit::new(class.clone()).serialize(serializer)
375    }
376
377    pub fn deserialize<'de, D>(deserializer: D) -> Result<PiiClass, D::Error>
378    where
379        D: Deserializer<'de>,
380    {
381        Ok(PiiClassAudit::deserialize(deserializer)?.into_inner())
382    }
383}
384
385/// A candidate recognizer/class pair that lost ambiguity resolution.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[non_exhaustive]
388pub struct LosingCandidate {
389    /// PII class proposed by the losing recognizer.
390    #[serde(with = "pii_class_audit_serde")]
391    pub class: PiiClass,
392    /// Stable recognizer identifier for traceability.
393    pub recognizer_id: String,
394}
395
396impl LosingCandidate {
397    /// Builds a losing ambiguity candidate.
398    pub fn new(class: PiiClass, recognizer_id: impl Into<String>) -> Self {
399        Self {
400            class,
401            recognizer_id: recognizer_id.into(),
402        }
403    }
404}
405
406/// Structured metadata describing an ambiguity outcome.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[non_exhaustive]
409pub struct AmbiguityRecord {
410    /// The family-level class assigned when disambiguation failed.
411    #[serde(with = "pii_class_audit_serde")]
412    pub ambiguity_class: PiiClass,
413    /// Variants that could not be disambiguated.
414    ///
415    /// Producers must keep this list stable by sorting `recognizer_id` ascending.
416    pub losing_candidates: Vec<LosingCandidate>,
417    /// Why disambiguation failed.
418    pub reason: AmbiguityReason,
419}
420
421impl AmbiguityRecord {
422    /// Builds a structured ambiguity record.
423    pub fn new(
424        ambiguity_class: PiiClass,
425        losing_candidates: Vec<LosingCandidate>,
426        reason: AmbiguityReason,
427    ) -> Self {
428        Self {
429            ambiguity_class,
430            losing_candidates,
431            reason,
432        }
433    }
434}
435
436/// Closed set of ambiguity outcomes recorded by the audit side-channel.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[non_exhaustive]
439#[serde(rename_all = "snake_case")]
440pub enum AmbiguityReason {
441    /// Span matched a multi-recognizer family and no anchor cue resolved it.
442    NoAnchor,
443    /// Multiple validator-stage recognizers remained viable for the same span.
444    ValidatorIndeterminate,
445    /// Span matched recognizers across two or more distinct PII class families.
446    MultiFamilyMatch,
447    /// Multiple variants had the same precedence and no discriminator resolved them.
448    PrecedenceTie,
449}
450
451/// Closed validator failure reasons recorded by audit metadata.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
453#[non_exhaustive]
454#[serde(rename_all = "snake_case")]
455pub enum ValidatorFailReason {
456    /// Luhn checksum validation failed.
457    LuhnFailed,
458    /// IBAN MOD-97 validation failed.
459    IbanMod97Failed,
460    /// Email RFC-style validation failed.
461    #[serde(alias = "email_rfc_failed")]
462    EmailRfcRejected,
463    /// E.164 phone validation failed.
464    #[serde(alias = "e164_phone_failed")]
465    PhoneE164Rejected,
466    /// National phone parser accepted the number but region validation failed.
467    PhoneNationalRegionMismatch,
468    /// IPv4 parser rejected the candidate.
469    Ipv4ParseFailed,
470    /// IPv6 parser rejected the candidate.
471    Ipv6ParseFailed,
472    /// EIP-55 Ethereum checksum validation failed.
473    EthEip55ChecksumFailed,
474    /// Aadhaar Verhoeff checksum validation failed.
475    AadhaarVerhoeffFailed,
476    /// French NIR MOD-97 key validation failed.
477    FrNirMod97Failed,
478    /// German Steuer-ID MOD 11,10 checksum validation failed.
479    DeSteuerIdMod1110Failed,
480    /// Dutch BSN MOD-11 checksum validation failed.
481    BsnMod11Failed,
482    /// Brazilian CPF MOD-11 checksum validation failed.
483    CpfMod11Failed,
484    /// Brazilian CNPJ MOD-11 checksum validation failed.
485    CnpjMod11Failed,
486    /// UK NHS number MOD-11 checksum validation failed.
487    UkNhsMod11Failed,
488}
489
490/// Typed validator outcome used by the pre-resolver validator-veto phase.
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[non_exhaustive]
493#[serde(rename_all = "snake_case")]
494pub enum ValidatorOutcome {
495    /// Candidate passed validation; canonical form may be supplied by the validator.
496    Pass { canonical_form: Option<String> },
497    /// Candidate failed validation with a closed, auditable reason.
498    Fail { reason: ValidatorFailReason },
499    /// Recognizer has no validator for this candidate.
500    NotApplicable,
501}
502
503/// Error returned when a rulepack names a validator unsupported by this build.
504#[derive(Debug, Clone, PartialEq, Eq, Error)]
505#[non_exhaustive]
506pub enum ValidatorKindParseError {
507    /// Validator kind is not known or is gated behind a disabled feature.
508    #[error("unsupported validator: {kind}")]
509    UnsupportedValidator {
510        /// Unsupported validator kind.
511        kind: String,
512    },
513}
514
515/// Closed set of validator implementations used by validator-backed recognizers.
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517#[non_exhaustive]
518pub enum ValidatorKind {
519    /// Basic email shape validator.
520    EmailRfc,
521    /// Parser-backed E.164 phone validator.
522    #[cfg(feature = "phone-parser")]
523    E164Phone,
524    /// Parser-backed national phone validator for a fixed region.
525    #[cfg(feature = "phone-parser")]
526    E164PhoneNational(Region),
527    /// Luhn checksum validator.
528    Luhn,
529    /// IBAN MOD-97 validator.
530    IbanMod97,
531    /// Strict decimal dotted-quad IPv4 parser.
532    Ipv4Parse,
533    /// RFC 4291 / RFC 5952 IPv6 textual parser.
534    Ipv6Parse,
535    /// EIP-55 Ethereum address checksum validator.
536    EthEip55,
537    /// Indian Aadhaar Verhoeff checksum validator.
538    AadhaarVerhoeff,
539    /// French NIR MOD-97 key validator.
540    FrNirMod97,
541    /// German Steuer-ID MOD 11,10 checksum validator.
542    DeSteuerIdMod1110,
543    /// Dutch BSN MOD-11 checksum validator.
544    BsnMod11,
545    /// Brazilian CPF MOD-11 checksum validator.
546    CpfMod11,
547    /// Brazilian CNPJ MOD-11 checksum validator.
548    CnpjMod11,
549    /// UK NHS number MOD-11 checksum validator.
550    UkNhsMod11,
551}
552
553/// Regions supported by national phone validators.
554#[cfg(feature = "phone-parser")]
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556#[non_exhaustive]
557pub enum Region {
558    /// Germany.
559    De,
560    /// United States.
561    Us,
562}
563
564impl ValidatorKind {
565    /// Parses a policy validator kind.
566    pub fn parse(s: &str) -> Result<Self, ValidatorKindParseError> {
567        match s {
568            "email_rfc" => Ok(Self::EmailRfc),
569            #[cfg(feature = "phone-parser")]
570            "e164_phone" => Ok(Self::E164Phone),
571            #[cfg(feature = "phone-parser")]
572            "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
573            #[cfg(feature = "phone-parser")]
574            "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
575            "luhn" => Ok(Self::Luhn),
576            "iban_mod97" => Ok(Self::IbanMod97),
577            "ipv4_parse" => Ok(Self::Ipv4Parse),
578            "ipv6_parse" => Ok(Self::Ipv6Parse),
579            "eth_eip55" => Ok(Self::EthEip55),
580            "aadhaar_verhoeff" => Ok(Self::AadhaarVerhoeff),
581            "fr_nir_mod97" => Ok(Self::FrNirMod97),
582            "de_steuer_id_mod1110" => Ok(Self::DeSteuerIdMod1110),
583            "bsn_mod11" => Ok(Self::BsnMod11),
584            "cpf_mod11" => Ok(Self::CpfMod11),
585            "cnpj_mod11" => Ok(Self::CnpjMod11),
586            "uk_nhs_mod11" => Ok(Self::UkNhsMod11),
587            other => Err(ValidatorKindParseError::UnsupportedValidator {
588                kind: other.to_string(),
589            }),
590        }
591    }
592
593    /// Returns whether the validator accepts the input.
594    pub fn validates(self, input: &str) -> bool {
595        match self {
596            Self::AadhaarVerhoeff => aadhaar_verhoeff_check(input),
597            Self::FrNirMod97 => fr_nir_mod97_check(input),
598            Self::DeSteuerIdMod1110 => de_steuer_id_mod1110_check(input),
599            Self::BsnMod11 => bsn_mod11_check(input),
600            Self::CpfMod11 => cpf_mod11_check(input),
601            Self::CnpjMod11 => cnpj_mod11_check(input),
602            Self::UkNhsMod11 => uk_nhs_mod11_check(input),
603            _ => self.canonical_form(input).is_some(),
604        }
605    }
606
607    /// Applies validation and returns a typed outcome for audit.
608    pub fn validate(self, input: &str) -> ValidatorOutcome {
609        match self.canonical_form(input) {
610            Some(canonical_form) => ValidatorOutcome::Pass {
611                canonical_form: Some(canonical_form),
612            },
613            None => ValidatorOutcome::Fail {
614                reason: self.fail_reason(),
615            },
616        }
617    }
618
619    /// Returns the canonical form for accepted input.
620    pub fn canonical_form(self, input: &str) -> Option<String> {
621        match self {
622            Self::EmailRfc => is_basic_email(input).then(|| input.to_string()),
623            #[cfg(feature = "phone-parser")]
624            Self::E164Phone => e164_phone_check(input).then(|| input.to_string()),
625            #[cfg(feature = "phone-parser")]
626            Self::E164PhoneNational(region) => validate_phone_national(region, input),
627            Self::Luhn => luhn_check(input).then(|| input.to_string()),
628            Self::IbanMod97 => iban_mod97_check(input).then(|| input.to_string()),
629            Self::Ipv4Parse => ipv4_parse_check(input).then(|| input.to_string()),
630            Self::Ipv6Parse => ipv6_parse_check(input).then(|| input.to_string()),
631            Self::EthEip55 => eth_eip55_check(input).then(|| input.to_string()),
632            Self::AadhaarVerhoeff => {
633                canonical_ascii_digits::<12>(input).filter(|_| aadhaar_verhoeff_check(input))
634            }
635            Self::FrNirMod97 => {
636                canonical_ascii_digits::<15>(input).filter(|_| fr_nir_mod97_check(input))
637            }
638            Self::DeSteuerIdMod1110 => {
639                canonical_ascii_digits::<11>(input).filter(|_| de_steuer_id_mod1110_check(input))
640            }
641            Self::BsnMod11 => canonical_ascii_digits::<9>(input).filter(|_| bsn_mod11_check(input)),
642            Self::CpfMod11 => {
643                canonical_ascii_digits::<11>(input).filter(|_| cpf_mod11_check(input))
644            }
645            Self::CnpjMod11 => {
646                canonical_ascii_digits::<14>(input).filter(|_| cnpj_mod11_check(input))
647            }
648            Self::UkNhsMod11 => {
649                canonical_ascii_digits::<10>(input).filter(|_| uk_nhs_mod11_check(input))
650            }
651        }
652    }
653
654    /// Returns the audit reason emitted when validation fails.
655    pub fn fail_reason(self) -> ValidatorFailReason {
656        match self {
657            Self::EmailRfc => ValidatorFailReason::EmailRfcRejected,
658            #[cfg(feature = "phone-parser")]
659            Self::E164Phone => ValidatorFailReason::PhoneE164Rejected,
660            #[cfg(feature = "phone-parser")]
661            Self::E164PhoneNational(_) => ValidatorFailReason::PhoneNationalRegionMismatch,
662            Self::Luhn => ValidatorFailReason::LuhnFailed,
663            Self::IbanMod97 => ValidatorFailReason::IbanMod97Failed,
664            Self::Ipv4Parse => ValidatorFailReason::Ipv4ParseFailed,
665            Self::Ipv6Parse => ValidatorFailReason::Ipv6ParseFailed,
666            Self::EthEip55 => ValidatorFailReason::EthEip55ChecksumFailed,
667            Self::AadhaarVerhoeff => ValidatorFailReason::AadhaarVerhoeffFailed,
668            Self::FrNirMod97 => ValidatorFailReason::FrNirMod97Failed,
669            Self::DeSteuerIdMod1110 => ValidatorFailReason::DeSteuerIdMod1110Failed,
670            Self::BsnMod11 => ValidatorFailReason::BsnMod11Failed,
671            Self::CpfMod11 => ValidatorFailReason::CpfMod11Failed,
672            Self::CnpjMod11 => ValidatorFailReason::CnpjMod11Failed,
673            Self::UkNhsMod11 => ValidatorFailReason::UkNhsMod11Failed,
674        }
675    }
676}
677
678fn is_basic_email(input: &str) -> bool {
679    let Some((local, domain)) = input.split_once('@') else {
680        return false;
681    };
682    !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
683}
684
685#[cfg(feature = "phone-parser")]
686fn e164_phone_check(input: &str) -> bool {
687    phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
688}
689
690#[cfg(feature = "phone-parser")]
691fn validate_phone_national(region: Region, input: &str) -> Option<String> {
692    let country = match region {
693        Region::De => phonenumber::country::DE,
694        Region::Us => phonenumber::country::US,
695    };
696    let expected_code = match region {
697        Region::De => 49,
698        Region::Us => 1,
699    };
700    let number = phonenumber::parse(Some(country), input).ok()?;
701    if number.country().code() != expected_code {
702        return None;
703    }
704    if number.is_valid() || is_safe_fixture_phone(region, input) {
705        return Some(number.format().mode(phonenumber::Mode::E164).to_string());
706    }
707    None
708}
709
710#[cfg(feature = "phone-parser")]
711fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
712    let digits = input
713        .chars()
714        .filter(char::is_ascii_digit)
715        .collect::<String>();
716    match region {
717        Region::Us => {
718            digits == "15550100"
719                || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
720        }
721        Region::De => matches!(
722            digits.as_str(),
723            "493000000000"
724                | "4915100000000"
725                | "4915550112233"
726                | "015550112233"
727                | "491710000000"
728                | "01710000000"
729        ),
730    }
731}
732
733fn luhn_check(input: &str) -> bool {
734    let mut digits = Vec::new();
735    for byte in input.bytes() {
736        if byte.is_ascii_whitespace() || byte == b'-' {
737            continue;
738        }
739        if !byte.is_ascii_digit() {
740            return false;
741        }
742        digits.push(byte - b'0');
743    }
744    if !(13..=19).contains(&digits.len()) {
745        return false;
746    }
747
748    let sum: u32 = digits
749        .iter()
750        .rev()
751        .enumerate()
752        .map(|(index, digit)| {
753            let mut value = u32::from(*digit);
754            if index % 2 == 1 {
755                value *= 2;
756                if value > 9 {
757                    value -= 9;
758                }
759            }
760            value
761        })
762        .sum();
763    sum.is_multiple_of(10)
764}
765
766fn iban_mod97_check(input: &str) -> bool {
767    let canonical = iban_canonicalize(input);
768    let bytes = canonical.as_bytes();
769    if bytes.len() < 4 {
770        return false;
771    }
772    if !bytes[0].is_ascii_uppercase()
773        || !bytes[1].is_ascii_uppercase()
774        || !bytes[2].is_ascii_digit()
775        || !bytes[3].is_ascii_digit()
776    {
777        return false;
778    }
779    let Some(expected_len) = iban_country_length(&bytes[..2]) else {
780        return false;
781    };
782    if bytes.len() != expected_len {
783        return false;
784    }
785    if !bytes.iter().all(u8::is_ascii_alphanumeric) {
786        return false;
787    }
788
789    let mut remainder = 0u32;
790    for byte in bytes[4..].iter().chain(bytes[..4].iter()).copied() {
791        match byte {
792            b'0'..=b'9' => {
793                remainder = (remainder * 10 + u32::from(byte - b'0')) % 97;
794            }
795            b'A'..=b'Z' => {
796                let value = u32::from(byte - b'A') + 10;
797                remainder = (remainder * 10 + value / 10) % 97;
798                remainder = (remainder * 10 + value % 10) % 97;
799            }
800            _ => return false,
801        }
802    }
803    remainder == 1
804}
805
806fn iban_country_length(country: &[u8]) -> Option<usize> {
807    // ISO 13616 IBAN Registry country lengths. MOD-97 alone has a false-accept
808    // rate near 1/97, so exact country length gates candidates before checksum.
809    match country {
810        b"AD" => Some(24),
811        b"AE" => Some(23),
812        b"AL" => Some(28),
813        b"AT" => Some(20),
814        b"AZ" => Some(28),
815        b"BA" => Some(20),
816        b"BE" => Some(16),
817        b"BG" => Some(22),
818        b"BH" => Some(22),
819        b"BI" => Some(27),
820        b"BR" => Some(29),
821        b"BY" => Some(28),
822        b"CH" => Some(21),
823        b"CR" => Some(22),
824        b"CY" => Some(28),
825        b"CZ" => Some(24),
826        b"DE" => Some(22),
827        b"DJ" => Some(27),
828        b"DK" => Some(18),
829        b"DO" => Some(28),
830        b"EE" => Some(20),
831        b"EG" => Some(29),
832        b"ES" => Some(24),
833        b"FI" => Some(18),
834        b"FK" => Some(18),
835        b"FO" => Some(18),
836        b"FR" => Some(27),
837        b"GB" => Some(22),
838        b"GE" => Some(22),
839        b"GI" => Some(23),
840        b"GL" => Some(18),
841        b"GR" => Some(27),
842        b"GT" => Some(28),
843        b"HN" => Some(28),
844        b"HR" => Some(21),
845        b"HU" => Some(28),
846        b"IE" => Some(22),
847        b"IL" => Some(23),
848        b"IQ" => Some(23),
849        b"IS" => Some(26),
850        b"IT" => Some(27),
851        b"JO" => Some(30),
852        b"KW" => Some(30),
853        b"KZ" => Some(20),
854        b"LB" => Some(28),
855        b"LC" => Some(32),
856        b"LI" => Some(21),
857        b"LT" => Some(20),
858        b"LU" => Some(20),
859        b"LV" => Some(21),
860        b"LY" => Some(25),
861        b"MC" => Some(27),
862        b"MD" => Some(24),
863        b"ME" => Some(22),
864        b"MK" => Some(19),
865        b"MN" => Some(20),
866        b"MR" => Some(27),
867        b"MT" => Some(31),
868        b"MU" => Some(30),
869        b"NI" => Some(28),
870        b"NL" => Some(18),
871        b"NO" => Some(15),
872        b"OM" => Some(23),
873        b"PK" => Some(24),
874        b"PL" => Some(28),
875        b"PS" => Some(29),
876        b"PT" => Some(25),
877        b"QA" => Some(29),
878        b"RO" => Some(24),
879        b"RS" => Some(22),
880        b"RU" => Some(33),
881        b"SA" => Some(24),
882        b"SC" => Some(31),
883        b"SD" => Some(18),
884        b"SE" => Some(24),
885        b"SI" => Some(19),
886        b"SK" => Some(24),
887        b"SM" => Some(27),
888        b"SO" => Some(23),
889        b"ST" => Some(25),
890        b"SV" => Some(28),
891        b"TL" => Some(23),
892        b"TN" => Some(24),
893        b"TR" => Some(26),
894        b"UA" => Some(29),
895        b"VA" => Some(22),
896        b"VG" => Some(24),
897        b"XK" => Some(20),
898        b"YE" => Some(30),
899        _ => None,
900    }
901}
902
903fn iban_canonicalize(input: &str) -> String {
904    input
905        .chars()
906        .filter(|ch| !ch.is_ascii_whitespace())
907        .flat_map(char::to_uppercase)
908        .collect()
909}
910
911fn ipv4_parse_check(input: &str) -> bool {
912    input.parse::<std::net::Ipv4Addr>().is_ok()
913}
914
915fn ipv6_parse_check(input: &str) -> bool {
916    input.parse::<std::net::Ipv6Addr>().is_ok()
917}
918
919fn eth_eip55_check(input: &str) -> bool {
920    let Some(address) = input.strip_prefix("0x") else {
921        return false;
922    };
923    if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
924        return false;
925    }
926    if address
927        .bytes()
928        .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
929        || address
930            .bytes()
931            .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
932    {
933        return true;
934    }
935
936    let lowercase = address.to_ascii_lowercase();
937    let hash = Keccak256::digest(lowercase.as_bytes());
938    for (index, byte) in address.bytes().enumerate() {
939        if byte.is_ascii_digit() {
940            continue;
941        }
942        let hash_nibble = if index % 2 == 0 {
943            hash[index / 2] >> 4
944        } else {
945            hash[index / 2] & 0x0f
946        };
947        if (hash_nibble > 7) != byte.is_ascii_uppercase() {
948            return false;
949        }
950    }
951    true
952}
953
954fn collect_ascii_digits<const N: usize>(input: &str) -> Option<[u8; N]> {
955    let mut digits = [0u8; N];
956    let mut count = 0usize;
957    for byte in input.bytes() {
958        if byte.is_ascii_digit() {
959            if count == N {
960                return None;
961            }
962            digits[count] = byte - b'0';
963            count += 1;
964        } else if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' | b'/') {
965            continue;
966        } else {
967            return None;
968        }
969    }
970    (count == N).then_some(digits)
971}
972
973fn canonical_ascii_digits<const N: usize>(input: &str) -> Option<String> {
974    let digits = collect_ascii_digits::<N>(input)?;
975    let mut canonical = String::with_capacity(N);
976    for digit in digits {
977        canonical.push(char::from(b'0' + digit));
978    }
979    Some(canonical)
980}
981
982fn not_all_same<const N: usize>(digits: &[u8; N]) -> bool {
983    digits[1..].iter().any(|digit| *digit != digits[0])
984}
985
986fn aadhaar_verhoeff_check(input: &str) -> bool {
987    const D: [[u8; 10]; 10] = [
988        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
989        [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
990        [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
991        [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
992        [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
993        [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
994        [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
995        [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
996        [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
997        [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
998    ];
999    const P: [[u8; 10]; 8] = [
1000        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1001        [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1002        [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1003        [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1004        [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1005        [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1006        [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1007        [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1008    ];
1009    let Some(digits) = collect_ascii_digits::<12>(input) else {
1010        return false;
1011    };
1012    if digits[0] < 2 || !not_all_same(&digits) {
1013        return false;
1014    }
1015    let mut checksum = 0u8;
1016    for (index, digit) in digits.iter().rev().enumerate() {
1017        checksum = D[checksum as usize][P[index % 8][*digit as usize] as usize];
1018    }
1019    checksum == 0
1020}
1021
1022fn fr_nir_mod97_check(input: &str) -> bool {
1023    let Some(digits) = collect_ascii_digits::<15>(input) else {
1024        return false;
1025    };
1026    if !matches!(digits[0], 1 | 2 | 3 | 4 | 7 | 8) {
1027        return false;
1028    }
1029    let month = digits[3] * 10 + digits[4];
1030    if !(1..=12).contains(&month) && !(20..=42).contains(&month) && !(50..=99).contains(&month) {
1031        return false;
1032    }
1033    let mut number = 0u32;
1034    for digit in &digits[..13] {
1035        number = (number * 10 + u32::from(*digit)) % 97;
1036    }
1037    let key = u32::from(digits[13]) * 10 + u32::from(digits[14]);
1038    97 - number == key
1039}
1040
1041fn de_steuer_id_mod1110_check(input: &str) -> bool {
1042    let Some(digits) = collect_ascii_digits::<11>(input) else {
1043        return false;
1044    };
1045    if !steuer_id_first_ten_digits_valid(&digits) {
1046        return false;
1047    }
1048    let mut product = 10u8;
1049    for digit in &digits[..10] {
1050        let mut sum = (*digit + product) % 10;
1051        if sum == 0 {
1052            sum = 10;
1053        }
1054        product = (2 * sum) % 11;
1055    }
1056    let check = (11 - product) % 10;
1057    check == digits[10]
1058}
1059
1060fn steuer_id_first_ten_digits_valid(digits: &[u8; 11]) -> bool {
1061    if digits[0] == 0 {
1062        return false;
1063    }
1064    let mut counts = [0u8; 10];
1065    for digit in &digits[..10] {
1066        counts[*digit as usize] += 1;
1067    }
1068    let repeated_digits = counts.iter().filter(|count| **count > 1).count();
1069    let missing_digits = counts.iter().filter(|count| **count == 0).count();
1070    let repeated_count_valid = counts.iter().any(|count| matches!(*count, 2 | 3));
1071    repeated_digits == 1 && repeated_count_valid && matches!(missing_digits, 1 | 2)
1072}
1073
1074fn bsn_mod11_check(input: &str) -> bool {
1075    let Some(digits) = collect_ascii_digits::<9>(input) else {
1076        return false;
1077    };
1078    if !not_all_same(&digits) {
1079        return false;
1080    }
1081    let sum: i32 = digits[..8]
1082        .iter()
1083        .enumerate()
1084        .map(|(index, digit)| i32::from(*digit) * (9 - index as i32))
1085        .sum::<i32>()
1086        - i32::from(digits[8]);
1087    sum.rem_euclid(11) == 0
1088}
1089
1090fn cpf_mod11_check(input: &str) -> bool {
1091    let Some(digits) = collect_ascii_digits::<11>(input) else {
1092        return false;
1093    };
1094    if !not_all_same(&digits) {
1095        return false;
1096    }
1097    mod11_check_digit(&digits[..9], 10) == digits[9]
1098        && mod11_check_digit(&digits[..10], 11) == digits[10]
1099}
1100
1101fn cnpj_mod11_check(input: &str) -> bool {
1102    let Some(digits) = collect_ascii_digits::<14>(input) else {
1103        return false;
1104    };
1105    if !not_all_same(&digits) {
1106        return false;
1107    }
1108    const FIRST: [u8; 12] = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1109    const SECOND: [u8; 13] = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1110    weighted_mod11_check_digit(&digits[..12], &FIRST) == digits[12]
1111        && weighted_mod11_check_digit(&digits[..13], &SECOND) == digits[13]
1112}
1113
1114fn uk_nhs_mod11_check(input: &str) -> bool {
1115    let Some(digits) = collect_ascii_digits::<10>(input) else {
1116        return false;
1117    };
1118    if !not_all_same(&digits) {
1119        return false;
1120    }
1121    let sum: u32 = digits[..9]
1122        .iter()
1123        .enumerate()
1124        .map(|(index, digit)| u32::from(*digit) * (10 - index as u32))
1125        .sum();
1126    let check = 11 - (sum % 11);
1127    let check = if check == 11 { 0 } else { check };
1128    check != 10 && check == u32::from(digits[9])
1129}
1130
1131fn mod11_check_digit(digits: &[u8], start_weight: u8) -> u8 {
1132    let weights = (2..=start_weight).rev();
1133    let sum: u32 = digits
1134        .iter()
1135        .zip(weights)
1136        .map(|(digit, weight)| u32::from(*digit) * u32::from(weight))
1137        .sum();
1138    let remainder = sum % 11;
1139    if remainder < 2 {
1140        0
1141    } else {
1142        (11 - remainder) as u8
1143    }
1144}
1145
1146fn weighted_mod11_check_digit(digits: &[u8], weights: &[u8]) -> u8 {
1147    let sum: u32 = digits
1148        .iter()
1149        .zip(weights)
1150        .map(|(digit, weight)| u32::from(*digit) * u32::from(*weight))
1151        .sum();
1152    let remainder = sum % 11;
1153    if remainder < 2 {
1154        0
1155    } else {
1156        (11 - remainder) as u8
1157    }
1158}
1159
1160/// A detected span and its class/source metadata.
1161#[derive(Debug, Clone, PartialEq, Eq)]
1162#[non_exhaustive]
1163pub struct Detection {
1164    /// Byte span in the original input.
1165    pub span: Range<usize>,
1166    /// PII class assigned to the span.
1167    pub class: PiiClass,
1168    /// Detector source identifier.
1169    pub source: String,
1170}
1171
1172impl Detection {
1173    /// Builds a detected PII span.
1174    pub fn new(span: Range<usize>, class: PiiClass, source: impl Into<String>) -> Self {
1175        Self {
1176            span,
1177            class,
1178            source: source.into(),
1179        }
1180    }
1181}
1182
1183/// Observer-only post-clean check (Pass 3 in the detection pipeline).
1184///
1185/// Runs against already-tokenized output. May report suspected missed PII via
1186/// [`LeakReport`] but **must not** mutate the token manifest, the `CleanDocument`,
1187/// or the restore path. Safety nets are additive defense-in-depth, not a replacement
1188/// for Pass 1/2 detection.
1189///
1190/// Activate at runtime with `Pipeline::with_safety_net` (post-build) or
1191/// `PipelineBuilder::register_safety_net` (during build), or via the CLI
1192/// `--safety-net=<name>` flag.
1193///
1194/// If a safety net reports a suspected miss, the caller decides the response; the
1195/// pipeline never silently re-cleans based on safety net output.
1196pub trait SafetyNet: Send + Sync {
1197    /// Stable backend identifier used in telemetry and audit rows.
1198    fn id(&self) -> &str;
1199
1200    /// Locale tags supported by this safety net. Empty means global.
1201    fn supported_locales(&self) -> &[LocaleTag];
1202
1203    /// Checks clean text for possible PII that the manifest did not cover.
1204    fn check(
1205        &self,
1206        clean_text: &str,
1207        context: SafetyNetContext<'_>,
1208    ) -> Result<Vec<LeakSuspect>, SafetyNetError>;
1209}
1210
1211/// Context passed to a privacy safety net.
1212#[derive(Debug, Clone, Copy)]
1213#[non_exhaustive]
1214pub struct SafetyNetContext<'a> {
1215    /// Tokens emitted by the pseudonymization pipeline for this text segment.
1216    pub manifest: &'a Manifest,
1217    /// Active session-level locale chain. For `RawDocument::Structured`, locale
1218    /// gating uses this same session-level chain across all fields; structured
1219    /// fields do not carry per-field locale annotations.
1220    pub locale_chain: &'a [LocaleTag],
1221    /// Source document kind being checked.
1222    pub document_kind: DocumentKind,
1223    /// Optional audit session identifier.
1224    pub session_id: Option<&'a str>,
1225    /// Structured-document field path, such as `$.user.email`.
1226    pub field_path: Option<&'a str>,
1227}
1228
1229impl<'a> SafetyNetContext<'a> {
1230    /// Builds safety-net context for one clean text segment.
1231    pub fn new(
1232        manifest: &'a Manifest,
1233        locale_chain: &'a [LocaleTag],
1234        document_kind: DocumentKind,
1235        session_id: Option<&'a str>,
1236        field_path: Option<&'a str>,
1237    ) -> Self {
1238        Self {
1239            manifest,
1240            locale_chain,
1241            document_kind,
1242            session_id,
1243            field_path,
1244        }
1245    }
1246}
1247
1248/// A replacement emitted by the pseudonymization pipeline.
1249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250#[non_exhaustive]
1251pub struct EmittedTokenSpan {
1252    /// Byte span in the clean text.
1253    pub clean_span: Range<usize>,
1254    /// Byte span in the raw text that produced the token.
1255    pub raw_span: Range<usize>,
1256    /// PII class represented by the emitted token.
1257    pub class: PiiClass,
1258}
1259
1260impl EmittedTokenSpan {
1261    /// Builds an emitted token span.
1262    pub fn new(clean_span: Range<usize>, raw_span: Range<usize>, class: PiiClass) -> Self {
1263        Self {
1264            clean_span,
1265            raw_span,
1266            class,
1267        }
1268    }
1269}
1270
1271/// Set of emitted token spans for one clean text segment.
1272#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1273#[non_exhaustive]
1274pub struct Manifest {
1275    /// Spans sorted by `clean_span.start`.
1276    pub spans: Vec<EmittedTokenSpan>,
1277}
1278
1279impl Manifest {
1280    /// Builds a manifest from spans and sorts them by clean byte start.
1281    pub fn from_spans(mut spans: Vec<EmittedTokenSpan>) -> Self {
1282        spans.sort_by_key(|span| (span.clean_span.start, span.clean_span.end));
1283        Self { spans }
1284    }
1285
1286    /// Diffs one safety-net suspect span against emitted token coverage.
1287    ///
1288    /// Returns `None` when the suspect span is continuously covered by emitted
1289    /// token spans of the same class. Internal gaps return
1290    /// `LeakKind::PartialBleed`. When multiple uncovered gaps exist, this method
1291    /// deterministically returns the first gap by byte offset; full gap
1292    /// enumeration is intentionally deferred to a future report format.
1293    pub fn diff_against(
1294        &self,
1295        suspect_span: &Range<usize>,
1296        suspect_class: &PiiClass,
1297    ) -> Option<LeakKind> {
1298        if suspect_span.is_empty() {
1299            return None;
1300        }
1301
1302        let start_idx = self
1303            .spans
1304            .partition_point(|span| span.clean_span.end <= suspect_span.start);
1305        let overlapping = self.spans[start_idx..]
1306            .iter()
1307            .take_while(|span| span.clean_span.start < suspect_span.end)
1308            .filter(|span| ranges_overlap(&span.clean_span, suspect_span))
1309            .collect::<Vec<_>>();
1310
1311        if overlapping.is_empty() {
1312            return Some(LeakKind::Uncovered);
1313        }
1314
1315        let mut cursor = suspect_span.start;
1316        let mut first_mismatch = None::<&EmittedTokenSpan>;
1317        for span in overlapping {
1318            if span.clean_span.start > cursor {
1319                return Some(LeakKind::PartialBleed {
1320                    uncovered: cursor..span.clean_span.start.min(suspect_span.end),
1321                });
1322            }
1323
1324            if span.clean_span.end > cursor {
1325                if first_mismatch.is_none() && &span.class != suspect_class {
1326                    first_mismatch = Some(span);
1327                }
1328                cursor = cursor.max(span.clean_span.end.min(suspect_span.end));
1329                if cursor >= suspect_span.end {
1330                    break;
1331                }
1332            }
1333        }
1334
1335        if cursor < suspect_span.end {
1336            return Some(LeakKind::PartialBleed {
1337                uncovered: cursor..suspect_span.end,
1338            });
1339        }
1340
1341        first_mismatch.map(|span| LeakKind::ClassMismatch {
1342            pipeline_class: span.class.clone(),
1343            safety_net_class: suspect_class.clone(),
1344        })
1345    }
1346}
1347
1348fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1349    left.start < right.end && right.start < left.end
1350}
1351
1352/// Suspected leak reported by an observer-only safety net.
1353#[derive(Debug, Clone, PartialEq)]
1354#[non_exhaustive]
1355pub struct LeakSuspect {
1356    /// Byte span in clean text.
1357    pub span: Range<usize>,
1358    /// Mapped PII class for the suspect.
1359    pub class: PiiClass,
1360    /// Safety-net backend identifier.
1361    pub safety_net_id: String,
1362    /// Optional backend confidence score.
1363    pub score: Option<f32>,
1364    /// Leak classification after manifest correlation.
1365    pub kind: LeakKind,
1366    /// Raw backend label after validation/mapping, never source text.
1367    pub raw_label: String,
1368    /// Optional structured field path.
1369    pub field_path: Option<String>,
1370}
1371
1372impl LeakSuspect {
1373    /// Builds a safety-net leak suspect.
1374    pub fn new(
1375        span: Range<usize>,
1376        class: PiiClass,
1377        safety_net_id: impl Into<String>,
1378        score: Option<f32>,
1379        kind: LeakKind,
1380        raw_label: impl Into<String>,
1381        field_path: Option<String>,
1382    ) -> Self {
1383        Self {
1384            span,
1385            class,
1386            safety_net_id: safety_net_id.into(),
1387            score,
1388            kind,
1389            raw_label: raw_label.into(),
1390            field_path,
1391        }
1392    }
1393}
1394
1395/// The category of a suspected missed PII span.
1396///
1397/// `LeakKind` is `#[non_exhaustive]`. Match with a wildcard for forward compatibility.
1398#[derive(Debug, Clone, PartialEq, Eq)]
1399#[non_exhaustive]
1400pub enum LeakKind {
1401    /// No same-class emitted token overlaps the suspect span.
1402    Uncovered,
1403    /// The suspect is only partly covered; `uncovered` is the first gap.
1404    PartialBleed {
1405        /// First uncovered byte range in the suspect span.
1406        uncovered: Range<usize>,
1407    },
1408    /// The suspect is continuously covered, but by a different class.
1409    ClassMismatch {
1410        /// Class emitted by the pipeline.
1411        pipeline_class: PiiClass,
1412        /// Class reported by the safety net.
1413        safety_net_class: PiiClass,
1414    },
1415}
1416
1417/// Bytes-free telemetry emitted by safety-net orchestration.
1418#[derive(Debug, Clone, PartialEq, Eq)]
1419#[non_exhaustive]
1420pub enum LeakReportTelemetry {
1421    /// Safety net skipped because the session-level locale chain did not match.
1422    LocaleSkipped {
1423        /// Safety-net backend identifier.
1424        safety_net_id: String,
1425        /// Document kind checked.
1426        document_kind: DocumentKind,
1427        /// Optional structured field path when skip was recorded per field.
1428        field_path: Option<String>,
1429    },
1430}
1431
1432/// Aggregate leak report statistics.
1433#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1434#[non_exhaustive]
1435pub struct LeakReportStats {
1436    /// Number of suspects reported.
1437    pub suspect_count: usize,
1438    /// Number of uncovered suspects.
1439    pub uncovered_count: usize,
1440    /// Number of partial-bleed suspects.
1441    pub partial_bleed_count: usize,
1442    /// Number of class-mismatch suspects.
1443    pub class_mismatch_count: usize,
1444    /// Number of locale-skip telemetry events.
1445    pub locale_skipped_count: usize,
1446}
1447
1448/// Signed document-context metadata carried inside a session snapshot envelope.
1449///
1450/// This extension is the v0.7 bridge for `gaze-document`: it is safe to serialize
1451/// inside the owner-only snapshot envelope, while agent-facing files keep using
1452/// non-sensitive mirrors. The single `schema_version` is bundle-level; sub-files
1453/// do not carry independent schema versions.
1454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1455#[non_exhaustive]
1456pub struct DocumentExtension {
1457    /// Bundle-level schema version shared by clean, layout, preview, report, and manifest files.
1458    pub schema_version: u16,
1459    /// SHA-256 of `clean.md` NFC-normalized bytes.
1460    pub clean_md_sha256: [u8; 32],
1461    /// SHA-256 of canonical `layout.json` bytes.
1462    pub layout_json_sha256: [u8; 32],
1463    /// SHA-256 of canonical `report.json` bytes.
1464    pub report_json_sha256: [u8; 32],
1465    /// SHA-256 of `preview-redacted.png` bytes when a preview is present.
1466    #[serde(default, skip_serializing_if = "Option::is_none")]
1467    pub preview_png_sha256: Option<[u8; 32]>,
1468    /// Page count reported for the source document.
1469    pub page_count: u32,
1470    /// Audit session id mirrored from the writing session for cross-pane correlation.
1471    pub audit_session_id: String,
1472    /// Signed clean.md byte spans for every emitted token.
1473    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1474    pub clean_spans: Vec<EmittedTokenSpan>,
1475    /// Codec audit rows for the decode path that produced this document extension.
1476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1477    pub codec_audit: Vec<CodecAuditRow>,
1478}
1479
1480impl DocumentExtension {
1481    /// Starts a document extension builder for one bundle schema version.
1482    pub fn builder(schema_version: u16) -> DocumentExtensionBuilder {
1483        DocumentExtensionBuilder {
1484            schema_version,
1485            clean_md_sha256: None,
1486            layout_json_sha256: None,
1487            report_json_sha256: None,
1488            preview_png_sha256: None,
1489            page_count: None,
1490            audit_session_id: None,
1491            clean_spans: Vec::new(),
1492            codec_audit: Vec::new(),
1493        }
1494    }
1495}
1496
1497/// Builder for [`DocumentExtension`] that requires signed integrity-binding fields.
1498#[derive(Debug, Clone)]
1499#[must_use]
1500pub struct DocumentExtensionBuilder {
1501    schema_version: u16,
1502    clean_md_sha256: Option<[u8; 32]>,
1503    layout_json_sha256: Option<[u8; 32]>,
1504    report_json_sha256: Option<[u8; 32]>,
1505    preview_png_sha256: Option<[u8; 32]>,
1506    page_count: Option<u32>,
1507    audit_session_id: Option<String>,
1508    clean_spans: Vec<EmittedTokenSpan>,
1509    codec_audit: Vec<CodecAuditRow>,
1510}
1511
1512impl DocumentExtensionBuilder {
1513    pub fn clean_md_sha256(mut self, hash: [u8; 32]) -> Self {
1514        self.clean_md_sha256 = Some(hash);
1515        self
1516    }
1517
1518    pub fn layout_json_sha256(mut self, hash: [u8; 32]) -> Self {
1519        self.layout_json_sha256 = Some(hash);
1520        self
1521    }
1522
1523    pub fn report_json_sha256(mut self, hash: [u8; 32]) -> Self {
1524        self.report_json_sha256 = Some(hash);
1525        self
1526    }
1527
1528    pub fn preview_png_sha256(mut self, hash: [u8; 32]) -> Self {
1529        self.preview_png_sha256 = Some(hash);
1530        self
1531    }
1532
1533    pub fn page_count(mut self, page_count: u32) -> Self {
1534        self.page_count = Some(page_count);
1535        self
1536    }
1537
1538    pub fn audit_session_id(mut self, audit_session_id: impl Into<String>) -> Self {
1539        self.audit_session_id = Some(audit_session_id.into());
1540        self
1541    }
1542
1543    pub fn clean_spans(mut self, clean_spans: Vec<EmittedTokenSpan>) -> Self {
1544        self.clean_spans = clean_spans;
1545        self
1546    }
1547
1548    pub fn codec_audit(mut self, codec_audit: Vec<CodecAuditRow>) -> Self {
1549        self.codec_audit = codec_audit;
1550        self
1551    }
1552
1553    pub fn build(self) -> Result<DocumentExtension, DocumentExtensionError> {
1554        Ok(DocumentExtension {
1555            schema_version: self.schema_version,
1556            clean_md_sha256: self
1557                .clean_md_sha256
1558                .ok_or(DocumentExtensionError::MissingField("clean_md_sha256"))?,
1559            layout_json_sha256: self
1560                .layout_json_sha256
1561                .ok_or(DocumentExtensionError::MissingField("layout_json_sha256"))?,
1562            report_json_sha256: self
1563                .report_json_sha256
1564                .ok_or(DocumentExtensionError::MissingField("report_json_sha256"))?,
1565            preview_png_sha256: self.preview_png_sha256,
1566            page_count: self
1567                .page_count
1568                .ok_or(DocumentExtensionError::MissingField("page_count"))?,
1569            audit_session_id: self
1570                .audit_session_id
1571                .ok_or(DocumentExtensionError::MissingField("audit_session_id"))?,
1572            clean_spans: self.clean_spans,
1573            codec_audit: self.codec_audit,
1574        })
1575    }
1576}
1577
1578/// Errors returned while building a [`DocumentExtension`].
1579#[derive(Debug, Clone, PartialEq, Eq, Error)]
1580#[non_exhaustive]
1581pub enum DocumentExtensionError {
1582    #[error("missing document extension field: {0}")]
1583    MissingField(&'static str),
1584}
1585
1586/// Provenance of text extracted from a document or transcript source.
1587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1588#[serde(rename_all = "snake_case")]
1589#[non_exhaustive]
1590pub enum TextOrigin {
1591    /// Text came from OCR over pixels.
1592    Ocr,
1593    /// Text came from an embedded text layer.
1594    EmbeddedText,
1595    /// Text came from an audio/video transcript.
1596    Transcript,
1597    /// Text came from multiple extraction paths.
1598    Hybrid,
1599}
1600
1601/// Orthogonal document codec capabilities delivered or advertised by a codec.
1602#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1603#[non_exhaustive]
1604pub struct CodecCapabilitySet {
1605    /// Codec can emit text.
1606    pub text: bool,
1607    /// Codec can emit layout geometry.
1608    pub layout: bool,
1609    /// Codec can emit confidence buckets.
1610    pub confidence: bool,
1611    /// Codec can emit timestamps.
1612    pub timestamps: bool,
1613}
1614
1615impl CodecCapabilitySet {
1616    /// Text-only capability set.
1617    pub const TEXT_ONLY: Self = Self {
1618        text: true,
1619        layout: false,
1620        confidence: false,
1621        timestamps: false,
1622    };
1623
1624    /// Builds a codec capability bitset.
1625    pub const fn new(text: bool, layout: bool, confidence: bool, timestamps: bool) -> Self {
1626        Self {
1627            text,
1628            layout,
1629            confidence,
1630            timestamps,
1631        }
1632    }
1633
1634    /// Returns true when this set contains every requested capability bit.
1635    pub fn contains(self, requested: Self) -> bool {
1636        (!requested.text || self.text)
1637            && (!requested.layout || self.layout)
1638            && (!requested.confidence || self.confidence)
1639            && (!requested.timestamps || self.timestamps)
1640    }
1641}
1642
1643/// Per-codec declaration for text extraction density checks.
1644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1645#[serde(rename_all = "snake_case")]
1646#[non_exhaustive]
1647pub enum ExtractionDensityPolicy {
1648    /// Require at least this many extracted text bytes per source KiB.
1649    Required(f32),
1650    /// Explicit exemption with an audit-visible reason.
1651    Exempt { reason: String },
1652}
1653
1654impl Default for ExtractionDensityPolicy {
1655    fn default() -> Self {
1656        Self::Exempt {
1657            reason: "calibration_pending".to_string(),
1658        }
1659    }
1660}
1661
1662/// Metadata-only audit row emitted by a document codec.
1663#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1664#[non_exhaustive]
1665pub struct CodecAuditRow {
1666    /// Stable codec id, such as `gaze.codec.tesseract`.
1667    pub codec_id: String,
1668    /// Adapter crate version, distinct from engine provenance.
1669    pub codec_version: String,
1670    /// Accepted MIME type for the decode.
1671    pub accepted_mime: String,
1672    /// Capabilities advertised by the codec.
1673    pub advertised: CodecCapabilitySet,
1674    /// Capabilities delivered for this decode.
1675    pub delivered: CodecCapabilitySet,
1676    /// Text provenance reported by the codec.
1677    pub text_origin: TextOrigin,
1678    /// Codec-output schema version, decoupled from bundle schema version.
1679    pub codec_output_schema_version: u16,
1680    /// Hash of canonical codec options, never the options themselves.
1681    #[serde(default, skip_serializing_if = "Option::is_none")]
1682    pub options_hash_hex: Option<String>,
1683    /// Engine provenance string, without paths or raw source text.
1684    #[serde(default, skip_serializing_if = "Option::is_none")]
1685    pub engine_provenance: Option<String>,
1686    /// Extraction density policy declared by the codec for this MIME.
1687    pub extraction_density_policy: ExtractionDensityPolicy,
1688}
1689
1690impl CodecAuditRow {
1691    /// Builds a metadata-only codec audit row.
1692    pub fn new(
1693        codec_id: impl Into<String>,
1694        codec_version: impl Into<String>,
1695        accepted_mime: impl Into<String>,
1696        text_origin: TextOrigin,
1697    ) -> Self {
1698        Self {
1699            codec_id: codec_id.into(),
1700            codec_version: codec_version.into(),
1701            accepted_mime: accepted_mime.into(),
1702            advertised: CodecCapabilitySet::default(),
1703            delivered: CodecCapabilitySet::default(),
1704            text_origin,
1705            codec_output_schema_version: 1,
1706            options_hash_hex: None,
1707            engine_provenance: None,
1708            extraction_density_policy: ExtractionDensityPolicy::default(),
1709        }
1710    }
1711}
1712
1713/// A suspected missed PII span reported by a [`SafetyNet`].
1714///
1715/// The safety net is not authoritative; a `LeakReport` is a signal, not a confirmed
1716/// leak. False positives are expected. Review reports and adjust policy or recognizer
1717/// thresholds.
1718#[derive(Debug, Clone, Default, PartialEq)]
1719#[non_exhaustive]
1720pub struct LeakReport {
1721    /// Suspected leaks, containing metadata only.
1722    pub suspects: Vec<LeakSuspect>,
1723    /// Bytes-free telemetry events.
1724    pub telemetry: Vec<LeakReportTelemetry>,
1725    /// Aggregated counts for callers that do not need full suspect metadata.
1726    pub stats: LeakReportStats,
1727    /// Optional replay hash.
1728    ///
1729    /// Replay determinism is guaranteed only when command path, checkpoint,
1730    /// operating point, min score, and decode parameters are fixed externally.
1731    pub replay_hash: Option<String>,
1732}
1733
1734impl LeakReport {
1735    /// Builds a report from suspects and telemetry.
1736    pub fn from_parts(
1737        suspects: Vec<LeakSuspect>,
1738        telemetry: Vec<LeakReportTelemetry>,
1739    ) -> LeakReport {
1740        let mut stats = LeakReportStats {
1741            suspect_count: suspects.len(),
1742            locale_skipped_count: telemetry
1743                .iter()
1744                .filter(|event| matches!(event, LeakReportTelemetry::LocaleSkipped { .. }))
1745                .count(),
1746            ..LeakReportStats::default()
1747        };
1748        for suspect in &suspects {
1749            match suspect.kind {
1750                LeakKind::Uncovered => stats.uncovered_count += 1,
1751                LeakKind::PartialBleed { .. } => stats.partial_bleed_count += 1,
1752                LeakKind::ClassMismatch { .. } => stats.class_mismatch_count += 1,
1753            }
1754        }
1755        LeakReport {
1756            suspects,
1757            telemetry,
1758            stats,
1759            replay_hash: None,
1760        }
1761    }
1762
1763    /// Merges another report into this report.
1764    pub fn extend(&mut self, other: LeakReport) {
1765        self.suspects.extend(other.suspects);
1766        self.telemetry.extend(other.telemetry);
1767        *self = LeakReport::from_parts(
1768            std::mem::take(&mut self.suspects),
1769            std::mem::take(&mut self.telemetry),
1770        );
1771    }
1772}
1773
1774/// Closed set of upstream OpenAI Privacy Filter labels accepted by Gaze.
1775#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1776#[non_exhaustive]
1777pub enum OpenAiPrivateLabel {
1778    /// `private_person`.
1779    PrivatePerson,
1780    /// `private_address`.
1781    PrivateAddress,
1782    /// `private_email`.
1783    PrivateEmail,
1784    /// `private_phone`.
1785    PrivatePhone,
1786    /// `private_url`.
1787    PrivateUrl,
1788    /// `private_date`.
1789    PrivateDate,
1790    /// `account_number`.
1791    AccountNumber,
1792    /// `secret`.
1793    Secret,
1794}
1795
1796impl OpenAiPrivateLabel {
1797    /// Returns the raw upstream label.
1798    pub fn as_str(self) -> &'static str {
1799        match self {
1800            Self::PrivatePerson => "private_person",
1801            Self::PrivateAddress => "private_address",
1802            Self::PrivateEmail => "private_email",
1803            Self::PrivatePhone => "private_phone",
1804            Self::PrivateUrl => "private_url",
1805            Self::PrivateDate => "private_date",
1806            Self::AccountNumber => "account_number",
1807            Self::Secret => "secret",
1808        }
1809    }
1810}
1811
1812/// Closed safety-net PII vocabulary before mapping into `PiiClass`.
1813#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1814#[non_exhaustive]
1815pub enum SafetyNetPiiClass {
1816    /// Email address.
1817    Email,
1818    /// Person name.
1819    Name,
1820    /// Location or address.
1821    Location,
1822    /// Phone number.
1823    Phone,
1824    /// URL.
1825    Url,
1826    /// Date.
1827    Date,
1828    /// Account number.
1829    AccountNumber,
1830    /// Secret.
1831    Secret,
1832}
1833
1834impl SafetyNetPiiClass {
1835    /// Maps the safety-net class into the shared pipeline class vocabulary.
1836    pub fn to_pii_class(self) -> PiiClass {
1837        match self {
1838            Self::Email => PiiClass::Email,
1839            Self::Name => PiiClass::Name,
1840            Self::Location => PiiClass::Location,
1841            Self::Phone => PiiClass::custom("phone"),
1842            Self::Url => PiiClass::custom("url"),
1843            Self::Date => PiiClass::custom("date"),
1844            Self::AccountNumber => PiiClass::custom("account_number"),
1845            Self::Secret => PiiClass::custom("secret"),
1846        }
1847    }
1848}
1849
1850/// Exhaustive, closed error set for safety-net execution.
1851#[derive(Debug, Clone, PartialEq, Eq, Error)]
1852#[non_exhaustive]
1853pub enum SafetyNetError {
1854    /// Safety net was explicitly requested but is unavailable.
1855    #[error("safety net unavailable: {reason}")]
1856    Unavailable {
1857        /// Sanitized reason.
1858        reason: String,
1859    },
1860    /// Required model weights or checkpoint are missing.
1861    #[error("safety net weights missing: {path}")]
1862    WeightsMissing {
1863        /// Sanitized path or identifier.
1864        path: String,
1865    },
1866    /// Backend model could not be loaded or reached.
1867    #[error("safety net model unavailable: {reason}")]
1868    ModelUnavailable {
1869        /// Sanitized reason.
1870        reason: String,
1871    },
1872    /// Backend model artifacts failed integrity verification.
1873    #[error("safety net model integrity mismatch: expected={expected}, actual={actual}")]
1874    ModelIntegrityMismatch {
1875        /// Expected SHA256 digest.
1876        expected: String,
1877        /// Actual SHA256 digest.
1878        actual: String,
1879    },
1880    /// Input exceeded configured backend limit.
1881    #[error("safety net input too large: limit={limit}, actual={actual}")]
1882    InputTooLarge {
1883        /// Configured byte limit.
1884        limit: usize,
1885        /// Actual byte length.
1886        actual: usize,
1887    },
1888    /// Backend runtime failed.
1889    #[error("safety net runtime failed: {message}")]
1890    Runtime {
1891        /// Sanitized diagnostic message.
1892        message: String,
1893    },
1894    /// Backend returned invalid output.
1895    #[error("safety net invalid output: {message}")]
1896    InvalidOutput {
1897        /// Sanitized diagnostic message.
1898        message: String,
1899    },
1900}
1901
1902/// Disposition applied to a detected PII span.
1903///
1904/// | Variant | Restorable | Output shape |
1905/// |---------|------------|--------------|
1906/// | `Tokenize` | Yes | Opaque token: `<hex:Class_N>` |
1907/// | `FormatPreserve` | Yes | Realistic-looking pseudonym (e.g., `email1.hex@gaze-fake.invalid`) |
1908/// | `Redact` | No | Literal `[REDACTED]` -- original value is gone |
1909/// | `Generalize` | No | Class label (e.g., `[Email]`) -- original value is gone |
1910/// | `Preserve` | - | Passes through unchanged |
1911///
1912/// `Action` is `#[non_exhaustive]`. Use a wildcard arm in exhaustive matches.
1913/// When restore is required, use `Tokenize` or `FormatPreserve` -- `Redact` and
1914/// `Generalize` are irreversible.
1915#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1916#[non_exhaustive]
1917pub enum Action {
1918    /// Replace PII with a reversible token.
1919    Tokenize,
1920    /// Replace PII with a non-restorable redaction marker.
1921    Redact,
1922    /// Replace PII with a reversible format-preserving token.
1923    FormatPreserve,
1924    /// Replace PII with a broader category.
1925    Generalize,
1926    /// Preserve the original value.
1927    Preserve,
1928}
1929
1930/// Conflict resolution tier that selected or rejected a candidate.
1931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932#[non_exhaustive]
1933pub enum ConflictTier {
1934    /// No conflict resolution was needed.
1935    None,
1936    /// Class priority decided the conflict.
1937    ClassPriority,
1938    /// Rule priority decided the conflict.
1939    RulePriority,
1940    /// Candidate score decided the conflict.
1941    Score,
1942    /// Span length decided the conflict.
1943    SpanLength,
1944    /// Same-class containment validator result decided the conflict.
1945    Validator,
1946    /// Pre-resolver validator veto rejected the candidate.
1947    ValidatorVeto,
1948    /// Cross-class collision-family policy decided the conflict.
1949    CollisionPolicy,
1950    /// Mandatory-anchor context was missing, so family-level fallback was emitted.
1951    AnchoredContext,
1952    /// Recognizer identifier decided the conflict.
1953    RecognizerId,
1954    /// Candidate was merged with another candidate.
1955    Merged,
1956    /// Safety-net redact mode stripped a suspect span.
1957    Redact,
1958    /// Safety-net resolve mode promoted a suspect span into a reversible token.
1959    Resolve,
1960    /// Safety-net fallback policy decided the outcome.
1961    Fallback,
1962}
1963
1964/// Safety-net fallback reason recorded in metadata-only audit rows.
1965#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1966#[non_exhaustive]
1967pub enum FallbackReason {
1968    /// The suspect overlapped an existing emitted token in a way that could not be promoted.
1969    OverlapConflict,
1970    /// A validator rejected the promoted candidate.
1971    ValidatorVeto,
1972    /// A mandatory anchor was missing for the promoted candidate.
1973    AnchorMissing,
1974    /// A follow-up safety-net pass still observed a suspect.
1975    ResidualSuspect,
1976}
1977
1978/// Source document kind for metadata-only audit logging.
1979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1980#[non_exhaustive]
1981pub enum DocumentKind {
1982    /// Structured key/value document.
1983    Structured,
1984    /// Plain text document.
1985    Text,
1986}
1987
1988/// One row of redaction metadata emitted to a [`RedactionLogger`].
1989///
1990/// Fields identify the PII class, action taken, session ID, source document kind,
1991/// conflict-resolution metadata, and timestamp. Does **not** contain the original PII
1992/// value, the token string, or any identifiable content beyond what a compliance audit
1993/// requires.
1994///
1995/// `RedactionEntry` is `#[non_exhaustive]`; adopters must construct via the public
1996/// constructor or destructure with a wildcard pattern.
1997#[derive(Debug, Clone, PartialEq, Eq)]
1998#[non_exhaustive]
1999pub struct RedactionEntry {
2000    /// Detector or recognizer source identifier.
2001    pub source: String,
2002    /// Stable semantic recognizer identifier, when available.
2003    pub recognizer_id: Option<String>,
2004    /// Versioned recognizer artifact/rule identifier, when available.
2005    pub recognizer_version_id: Option<String>,
2006    /// PII class affected by the decision.
2007    pub class: PiiClass,
2008    /// Policy action applied to the span.
2009    pub action: Action,
2010    /// Optional structured field name.
2011    pub field_name: Option<String>,
2012    /// Source document kind.
2013    pub document_kind: DocumentKind,
2014    /// Whether this entry records a loser in conflict resolution.
2015    pub conflict_loser: bool,
2016    /// Conflict tier that decided the outcome.
2017    pub decided_by: ConflictTier,
2018    /// Creation timestamp in epoch milliseconds.
2019    pub created_at: i64,
2020    /// Optional session identifier.
2021    pub session_id: Option<String>,
2022    /// Optional validator failure reason for a vetoed candidate.
2023    pub validator_fail_reason: Option<ValidatorFailReason>,
2024    /// Optional ambiguity metadata for a family-level fallback.
2025    pub ambiguity_record: Option<AmbiguityRecord>,
2026    /// Collision family that influenced this decision.
2027    pub collision_family: Option<String>,
2028    /// Collision variant that influenced this decision.
2029    pub collision_variant: Option<String>,
2030    /// Safety-net fallback reason, when fallback policy handled the row.
2031    pub fallback_triggered: Option<FallbackReason>,
2032    /// NER/pipeline provenance stage for audit-only producer attribution.
2033    pub provenance_stage: Option<String>,
2034    pub provenance_model_id: Option<String>,
2035    pub provenance_model_version: Option<String>,
2036    pub provenance_artifact_sha256: Option<String>,
2037    pub provenance_tokenizer_sha256: Option<String>,
2038    pub provenance_locale_resolved: Option<String>,
2039    pub provenance_locale_match_kind: Option<String>,
2040    pub provenance_canonical_class: Option<String>,
2041    pub provenance_native_class: Option<String>,
2042    pub provenance_confidence: Option<String>,
2043    pub provenance_merged_from: Option<String>,
2044    /// Locale-aware safety-net backend ids dropped by first-match-wins routing.
2045    pub backend_silently_dropped: Option<Vec<String>>,
2046    pub restore_policy: Option<String>,
2047    pub restore_decision: Option<String>,
2048    pub restore_unknown_token_count: Option<u64>,
2049    pub restore_manifest_bypass_count: Option<u64>,
2050    pub restore_fresh_pii_count: Option<u64>,
2051    pub restore_phase_mask: Option<u32>,
2052}
2053
2054impl Serialize for RedactionEntry {
2055    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2056    where
2057        S: serde::Serializer,
2058    {
2059        use serde::ser::SerializeStruct;
2060
2061        let mut len = 14;
2062        if self.recognizer_id.is_some() {
2063            len += 1;
2064        }
2065        if self.recognizer_version_id.is_some() {
2066            len += 1;
2067        }
2068        len += [
2069            self.provenance_stage.as_ref(),
2070            self.provenance_model_id.as_ref(),
2071            self.provenance_model_version.as_ref(),
2072            self.provenance_artifact_sha256.as_ref(),
2073            self.provenance_tokenizer_sha256.as_ref(),
2074            self.provenance_locale_resolved.as_ref(),
2075            self.provenance_locale_match_kind.as_ref(),
2076            self.provenance_canonical_class.as_ref(),
2077            self.provenance_native_class.as_ref(),
2078            self.provenance_confidence.as_ref(),
2079            self.provenance_merged_from.as_ref(),
2080        ]
2081        .into_iter()
2082        .filter(|value| value.is_some())
2083        .count();
2084        if self.backend_silently_dropped.is_some() {
2085            len += 1;
2086        }
2087        len += [self.restore_policy.as_ref(), self.restore_decision.as_ref()]
2088            .into_iter()
2089            .filter(|value| value.is_some())
2090            .count();
2091        len += [
2092            self.restore_unknown_token_count.is_some(),
2093            self.restore_manifest_bypass_count.is_some(),
2094            self.restore_fresh_pii_count.is_some(),
2095            self.restore_phase_mask.is_some(),
2096        ]
2097        .into_iter()
2098        .filter(|value| *value)
2099        .count();
2100        let mut state = serializer.serialize_struct("RedactionEntry", len)?;
2101        state.serialize_field("source", &self.source)?;
2102        if let Some(recognizer_id) = &self.recognizer_id {
2103            state.serialize_field("recognizer_id", recognizer_id)?;
2104        }
2105        if let Some(recognizer_version_id) = &self.recognizer_version_id {
2106            state.serialize_field("recognizer_version_id", recognizer_version_id)?;
2107        }
2108        state.serialize_field("class", &self.class.to_canonical_str())?;
2109        state.serialize_field("action", redaction_action_as_str(self.action))?;
2110        state.serialize_field("field_name", &self.field_name)?;
2111        state.serialize_field(
2112            "document_kind",
2113            redaction_document_kind_as_str(self.document_kind),
2114        )?;
2115        state.serialize_field("conflict_loser", &self.conflict_loser)?;
2116        state.serialize_field(
2117            "decided_by",
2118            redaction_conflict_tier_as_str(self.decided_by),
2119        )?;
2120        state.serialize_field("created_at", &self.created_at)?;
2121        state.serialize_field("session_id", &self.session_id)?;
2122        state.serialize_field("validator_fail_reason", &self.validator_fail_reason)?;
2123        state.serialize_field("ambiguity_record", &self.ambiguity_record)?;
2124        state.serialize_field("collision_family", &self.collision_family)?;
2125        state.serialize_field("collision_variant", &self.collision_variant)?;
2126        state.serialize_field("fallback_triggered", &self.fallback_triggered)?;
2127        if let Some(value) = &self.provenance_stage {
2128            state.serialize_field("provenance_stage", value)?;
2129        }
2130        if let Some(value) = &self.provenance_model_id {
2131            state.serialize_field("provenance_model_id", value)?;
2132        }
2133        if let Some(value) = &self.provenance_model_version {
2134            state.serialize_field("provenance_model_version", value)?;
2135        }
2136        if let Some(value) = &self.provenance_artifact_sha256 {
2137            state.serialize_field("provenance_artifact_sha256", value)?;
2138        }
2139        if let Some(value) = &self.provenance_tokenizer_sha256 {
2140            state.serialize_field("provenance_tokenizer_sha256", value)?;
2141        }
2142        if let Some(value) = &self.provenance_locale_resolved {
2143            state.serialize_field("provenance_locale_resolved", value)?;
2144        }
2145        if let Some(value) = &self.provenance_locale_match_kind {
2146            state.serialize_field("provenance_locale_match_kind", value)?;
2147        }
2148        if let Some(value) = &self.provenance_canonical_class {
2149            state.serialize_field("provenance_canonical_class", value)?;
2150        }
2151        if let Some(value) = &self.provenance_native_class {
2152            state.serialize_field("provenance_native_class", value)?;
2153        }
2154        if let Some(value) = &self.provenance_confidence {
2155            state.serialize_field("provenance_confidence", value)?;
2156        }
2157        if let Some(value) = &self.provenance_merged_from {
2158            state.serialize_field("provenance_merged_from", value)?;
2159        }
2160        if let Some(dropped) = &self.backend_silently_dropped {
2161            state.serialize_field("backend_silently_dropped", dropped)?;
2162        }
2163        if let Some(value) = &self.restore_policy {
2164            state.serialize_field("restore_policy", value)?;
2165        }
2166        if let Some(value) = &self.restore_decision {
2167            state.serialize_field("restore_decision", value)?;
2168        }
2169        if let Some(value) = self.restore_unknown_token_count {
2170            state.serialize_field("restore_unknown_token_count", &value)?;
2171        }
2172        if let Some(value) = self.restore_manifest_bypass_count {
2173            state.serialize_field("restore_manifest_bypass_count", &value)?;
2174        }
2175        if let Some(value) = self.restore_fresh_pii_count {
2176            state.serialize_field("restore_fresh_pii_count", &value)?;
2177        }
2178        if let Some(value) = self.restore_phase_mask {
2179            state.serialize_field("restore_phase_mask", &value)?;
2180        }
2181        state.end()
2182    }
2183}
2184
2185fn redaction_action_as_str(action: Action) -> &'static str {
2186    match action {
2187        Action::Tokenize => "tokenize",
2188        Action::Redact => "redact",
2189        Action::FormatPreserve => "format_preserve",
2190        Action::Generalize => "generalize",
2191        Action::Preserve => "preserve",
2192    }
2193}
2194
2195fn redaction_document_kind_as_str(kind: DocumentKind) -> &'static str {
2196    match kind {
2197        DocumentKind::Structured => "structured",
2198        DocumentKind::Text => "text",
2199    }
2200}
2201
2202fn redaction_conflict_tier_as_str(tier: ConflictTier) -> &'static str {
2203    match tier {
2204        ConflictTier::None => "none",
2205        ConflictTier::ClassPriority => "class_priority",
2206        ConflictTier::RulePriority => "rule_priority",
2207        ConflictTier::Score => "score",
2208        ConflictTier::SpanLength => "span_length",
2209        ConflictTier::Validator => "validator",
2210        ConflictTier::ValidatorVeto => "validator_veto",
2211        ConflictTier::CollisionPolicy => "collision_policy",
2212        ConflictTier::AnchoredContext => "anchored_context",
2213        ConflictTier::RecognizerId => "recognizer_id",
2214        ConflictTier::Merged => "merged",
2215        ConflictTier::Redact => "redact",
2216        ConflictTier::Resolve => "resolve",
2217        ConflictTier::Fallback => "fallback",
2218    }
2219}
2220
2221impl RedactionEntry {
2222    /// Builds a metadata-only redaction log entry.
2223    #[allow(clippy::too_many_arguments)]
2224    pub fn new(
2225        source: impl Into<String>,
2226        class: PiiClass,
2227        action: Action,
2228        field_name: Option<String>,
2229        document_kind: DocumentKind,
2230        conflict_loser: bool,
2231        decided_by: ConflictTier,
2232        created_at: i64,
2233        session_id: Option<String>,
2234    ) -> Self {
2235        Self {
2236            source: source.into(),
2237            class,
2238            action,
2239            field_name,
2240            document_kind,
2241            conflict_loser,
2242            decided_by,
2243            created_at,
2244            session_id,
2245            recognizer_id: None,
2246            recognizer_version_id: None,
2247            validator_fail_reason: None,
2248            ambiguity_record: None,
2249            collision_family: None,
2250            collision_variant: None,
2251            fallback_triggered: None,
2252            provenance_stage: None,
2253            provenance_model_id: None,
2254            provenance_model_version: None,
2255            provenance_artifact_sha256: None,
2256            provenance_tokenizer_sha256: None,
2257            provenance_locale_resolved: None,
2258            provenance_locale_match_kind: None,
2259            provenance_canonical_class: None,
2260            provenance_native_class: None,
2261            provenance_confidence: None,
2262            provenance_merged_from: None,
2263            backend_silently_dropped: None,
2264            restore_policy: None,
2265            restore_decision: None,
2266            restore_unknown_token_count: None,
2267            restore_manifest_bypass_count: None,
2268            restore_fresh_pii_count: None,
2269            restore_phase_mask: None,
2270        }
2271    }
2272
2273    /// Attaches a validator failure reason to this metadata row.
2274    pub fn with_validator_fail_reason(mut self, reason: ValidatorFailReason) -> Self {
2275        self.validator_fail_reason = Some(reason);
2276        self
2277    }
2278
2279    /// Attaches an ambiguity record to this metadata row.
2280    pub fn with_ambiguity_record(mut self, record: AmbiguityRecord) -> Self {
2281        self.ambiguity_record = Some(record);
2282        self
2283    }
2284
2285    /// Attaches collision-family metadata to this row.
2286    pub fn with_collision_metadata(
2287        mut self,
2288        family: Option<String>,
2289        variant: Option<String>,
2290    ) -> Self {
2291        self.collision_family = family;
2292        self.collision_variant = variant;
2293        self
2294    }
2295
2296    /// Attaches safety-net fallback metadata to this row.
2297    pub fn with_fallback_triggered(mut self, reason: FallbackReason) -> Self {
2298        self.fallback_triggered = Some(reason);
2299        self
2300    }
2301
2302    /// Attaches locale-aware backend ids dropped by first-match-wins routing.
2303    pub fn with_backend_silently_dropped(mut self, dropped: Vec<String>) -> Self {
2304        self.backend_silently_dropped = Some(dropped);
2305        self
2306    }
2307
2308    pub fn with_restore_telemetry(mut self, telemetry: RestoreTelemetry) -> Self {
2309        self.restore_policy = Some(telemetry.restore_policy_str().to_string());
2310        self.restore_decision = Some(telemetry.restore_decision_str().to_string());
2311        self.restore_unknown_token_count = Some(telemetry.unknown_token_count);
2312        self.restore_manifest_bypass_count = Some(telemetry.manifest_bypass_count);
2313        self.restore_fresh_pii_count = Some(telemetry.fresh_pii_detected_count);
2314        self.restore_phase_mask = Some(telemetry.phase_execution_mask);
2315        self
2316    }
2317
2318    /// Attaches recognizer lineage metadata to this row.
2319    pub fn with_recognizer_metadata(
2320        mut self,
2321        recognizer_id: Option<String>,
2322        recognizer_version_id: Option<String>,
2323    ) -> Self {
2324        self.recognizer_id = recognizer_id;
2325        self.recognizer_version_id = recognizer_version_id;
2326        self
2327    }
2328
2329    #[allow(clippy::too_many_arguments)]
2330    pub fn with_provenance_metadata(
2331        mut self,
2332        stage: Option<String>,
2333        model_id: Option<String>,
2334        model_version: Option<String>,
2335        artifact_sha256: Option<String>,
2336        tokenizer_sha256: Option<String>,
2337        locale_resolved: Option<String>,
2338        locale_match_kind: Option<String>,
2339        canonical_class: Option<String>,
2340        native_class: Option<String>,
2341        confidence: Option<f64>,
2342        merged_from: Option<String>,
2343    ) -> Self {
2344        self.provenance_stage = stage;
2345        self.provenance_model_id = model_id;
2346        self.provenance_model_version = model_version;
2347        self.provenance_artifact_sha256 = artifact_sha256;
2348        self.provenance_tokenizer_sha256 = tokenizer_sha256;
2349        self.provenance_locale_resolved = locale_resolved;
2350        self.provenance_locale_match_kind = locale_match_kind;
2351        self.provenance_canonical_class = canonical_class;
2352        self.provenance_native_class = native_class;
2353        self.provenance_confidence = confidence.map(|value| value.to_string());
2354        self.provenance_merged_from = merged_from;
2355        self
2356    }
2357}
2358
2359/// Closed error set for redaction log sinks.
2360#[derive(Debug, Clone, PartialEq, Eq, Error)]
2361#[non_exhaustive]
2362pub enum RedactionLogError {
2363    /// SQLite-backed redaction log sink failed.
2364    #[error("sqlite redaction log error: {0}")]
2365    Sqlite(String),
2366    /// Non-SQLite redaction log sink failed.
2367    #[error("backend redaction log error: {0}")]
2368    Backend(String),
2369}
2370
2371/// Trait for audit sinks that receive redaction metadata.
2372///
2373/// Implement this for custom audit backends (remote telemetry, structured JSON logs).
2374/// For SQLite-backed persistence, use `gaze_audit::SqliteLogger`.
2375///
2376/// # Contract
2377///
2378/// The logger receives **metadata only**: class, action, session ID, timestamp, and
2379/// other bytes-free audit labels. It never receives the original PII value or the token
2380/// value. A custom impl that augments entries with raw document text violates the audit
2381/// isolation contract and will be flagged by the `gaze_module_isolation` Dylint lint
2382/// when it lives in the wrong crate.
2383///
2384/// # Example
2385///
2386/// ```rust
2387/// use std::sync::atomic::{AtomicUsize, Ordering};
2388/// use gaze_types::{RedactionEntry, RedactionLogError, RedactionLogger};
2389///
2390/// #[derive(Default)]
2391/// struct CountLogger(AtomicUsize);
2392///
2393/// impl RedactionLogger for CountLogger {
2394///     fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
2395///         self.0.fetch_add(1, Ordering::Relaxed);
2396///         Ok(())
2397///     }
2398/// }
2399/// ```
2400pub trait RedactionLogger: Send + Sync {
2401    /// Records a metadata-only redaction entry.
2402    fn log(&self, entry: &RedactionEntry) -> Result<(), RedactionLogError>;
2403}
2404
2405/// Rulepack recognizer activation tier.
2406#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2407#[non_exhaustive]
2408pub enum SafetyTier {
2409    /// Activates whenever the recognizer's locale projection intersects the active locale chain.
2410    #[default]
2411    SafeDefault,
2412    /// Activates only when an explicit locale or compatibility alias enables locale-shaped rules.
2413    LocaleGated,
2414    /// Activates only through adopter opt-in surfaces such as policy-defined custom recognizers.
2415    OptIn,
2416}
2417
2418/// Safety-tier parsing error.
2419#[derive(Debug, Clone, PartialEq, Eq)]
2420#[non_exhaustive]
2421pub struct SafetyTierParseError {
2422    value: String,
2423}
2424
2425impl SafetyTier {
2426    /// Parses the TOML `safety_tier` string.
2427    pub fn parse(value: &str) -> Result<Self, SafetyTierParseError> {
2428        match value {
2429            "safe_default" => Ok(Self::SafeDefault),
2430            "locale_gated" => Ok(Self::LocaleGated),
2431            "opt_in" => Ok(Self::OptIn),
2432            other => Err(SafetyTierParseError {
2433                value: other.to_string(),
2434            }),
2435        }
2436    }
2437
2438    /// Returns the TOML string for this tier.
2439    pub fn as_str(self) -> &'static str {
2440        match self {
2441            Self::SafeDefault => "safe_default",
2442            Self::LocaleGated => "locale_gated",
2443            Self::OptIn => "opt_in",
2444        }
2445    }
2446}
2447
2448impl SafetyTierParseError {
2449    /// Returns the rejected safety-tier string.
2450    pub fn value(&self) -> &str {
2451        &self.value
2452    }
2453}
2454
2455impl fmt::Display for SafetyTierParseError {
2456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2457        write!(f, "unsupported safety_tier '{}'", self.value)
2458    }
2459}
2460
2461impl std::error::Error for SafetyTierParseError {}
2462
2463/// Locale tag recognized by policy and recognizers.
2464#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2465#[non_exhaustive]
2466pub enum LocaleTag {
2467    /// Locale-independent recognizer or policy.
2468    Global,
2469    /// German as used in Germany.
2470    DeDe,
2471    /// German as used in Austria.
2472    DeAt,
2473    /// German as used in Switzerland.
2474    DeCh,
2475    /// English as used in the United States.
2476    EnUs,
2477    /// English as used in Great Britain.
2478    EnGb,
2479    /// English as used in Ireland.
2480    EnIe,
2481    /// English as used in Australia.
2482    EnAu,
2483    /// English as used in Canada.
2484    EnCa,
2485    /// Any other canonical BCP-47-like tag.
2486    Other(String),
2487}
2488
2489/// Locale parsing error.
2490#[derive(Debug, Clone, PartialEq, Eq)]
2491#[non_exhaustive]
2492pub enum LocaleError {
2493    /// Locale tag is unsupported or invalid.
2494    Unsupported,
2495}
2496
2497impl fmt::Display for LocaleError {
2498    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2499        match self {
2500            LocaleError::Unsupported => f.write_str("unsupported locale"),
2501        }
2502    }
2503}
2504
2505impl std::error::Error for LocaleError {}
2506
2507/// Ordered locale fallback chain.
2508#[derive(Debug, Clone, PartialEq, Eq)]
2509pub struct LocaleChain(Vec<LocaleTag>);
2510
2511impl LocaleTag {
2512    /// Global locale constant.
2513    pub const GLOBAL: LocaleTag = LocaleTag::Global;
2514
2515    /// Parses a locale tag from policy or CLI input.
2516    pub fn parse(s: &str) -> Result<LocaleTag, LocaleError> {
2517        let raw = s.trim().replace('_', "-");
2518        let normalized = raw.to_ascii_lowercase();
2519        match normalized.as_str() {
2520            "global" | "*" => Ok(LocaleTag::Global),
2521            "de-de" => Ok(LocaleTag::DeDe),
2522            "de-at" => Ok(LocaleTag::DeAt),
2523            "de-ch" => Ok(LocaleTag::DeCh),
2524            "en-us" => Ok(LocaleTag::EnUs),
2525            "en-gb" => Ok(LocaleTag::EnGb),
2526            "en-ie" => Ok(LocaleTag::EnIe),
2527            "en-au" => Ok(LocaleTag::EnAu),
2528            "en-ca" => Ok(LocaleTag::EnCa),
2529            "" => Err(LocaleError::Unsupported),
2530            _ if is_bcp47_parseable(&raw) => Ok(LocaleTag::Other(canonical_other(&raw))),
2531            _ => Err(LocaleError::Unsupported),
2532        }
2533    }
2534
2535    /// Returns the canonical string form of the locale tag.
2536    pub fn as_str(&self) -> &str {
2537        match self {
2538            LocaleTag::Global => "global",
2539            LocaleTag::DeDe => "de-DE",
2540            LocaleTag::DeAt => "de-AT",
2541            LocaleTag::DeCh => "de-CH",
2542            LocaleTag::EnUs => "en-US",
2543            LocaleTag::EnGb => "en-GB",
2544            LocaleTag::EnIe => "en-IE",
2545            LocaleTag::EnAu => "en-AU",
2546            LocaleTag::EnCa => "en-CA",
2547            LocaleTag::Other(tag) => tag.as_str(),
2548        }
2549    }
2550}
2551
2552impl LocaleChain {
2553    /// Builds a locale chain and appends global fallback when absent.
2554    pub fn from_tags(mut tags: Vec<LocaleTag>) -> LocaleChain {
2555        ensure_global(&mut tags);
2556        LocaleChain(tags)
2557    }
2558
2559    /// Parses a comma-separated CLI locale chain.
2560    pub fn from_cli(raw: &str) -> Result<LocaleChain, LocaleError> {
2561        let tags = raw
2562            .split(',')
2563            .map(LocaleTag::parse)
2564            .collect::<Result<Vec<_>, _>>()?;
2565        Ok(LocaleChain::from_tags(tags))
2566    }
2567
2568    /// Merges policy and CLI locale preferences.
2569    pub fn merge_policy_and_cli(
2570        policy: Option<&[LocaleTag]>,
2571        cli: Option<&[LocaleTag]>,
2572    ) -> LocaleChain {
2573        Self::merge_cli_policy_rulepack_default(cli, policy, None)
2574    }
2575
2576    /// Merges CLI, policy, rulepack, and default locale preferences.
2577    pub fn merge_cli_policy_rulepack_default(
2578        cli: Option<&[LocaleTag]>,
2579        policy: Option<&[LocaleTag]>,
2580        rulepack_defaults: Option<&[LocaleTag]>,
2581    ) -> LocaleChain {
2582        let tags = cli
2583            .filter(|tags| !tags.is_empty())
2584            .or_else(|| policy.filter(|tags| !tags.is_empty()))
2585            .or_else(|| rulepack_defaults.filter(|tags| !tags.is_empty()))
2586            .map(|tags| tags.to_vec())
2587            .unwrap_or_else(|| vec![LocaleTag::Global]);
2588        LocaleChain::from_tags(tags)
2589    }
2590
2591    /// Returns true when a recognizer can run under this locale chain.
2592    pub fn intersects(&self, recognizer_locales: &[LocaleTag]) -> bool {
2593        if recognizer_locales.is_empty() {
2594            return true;
2595        }
2596        recognizer_locales.iter().any(|recognizer_locale| {
2597            *recognizer_locale == LocaleTag::Global
2598                || self.0.iter().any(|active| active == recognizer_locale)
2599        })
2600    }
2601
2602    /// Returns the locale tags in chain order.
2603    pub fn as_slice(&self) -> &[LocaleTag] {
2604        &self.0
2605    }
2606
2607    /// Returns the locale chain as canonical strings.
2608    pub fn to_strings(&self) -> Vec<String> {
2609        self.0.iter().map(ToString::to_string).collect()
2610    }
2611}
2612
2613impl From<&[LocaleTag]> for LocaleChain {
2614    fn from(tags: &[LocaleTag]) -> Self {
2615        let mut owned = tags.to_vec();
2616        ensure_global(&mut owned);
2617        LocaleChain(owned)
2618    }
2619}
2620
2621impl fmt::Display for LocaleTag {
2622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2623        f.write_str(self.as_str())
2624    }
2625}
2626
2627/// The input document submitted for pseudonymization.
2628///
2629/// `RawDocument::Text(String)` for plain or semi-structured text (most LLM workflows).
2630/// `RawDocument::Structured(BTreeMap<String, Value>)` for JSON-shaped data where
2631/// column-aware rules apply -- `ColumnRule`s only take effect on structured input.
2632///
2633/// `Detection::span` and recognizer candidate spans use **byte** ranges, not char indices.
2634///
2635/// `RawDocument` is `#[non_exhaustive]`. Match with a wildcard arm.
2636#[derive(Debug, Clone)]
2637#[non_exhaustive]
2638pub enum RawDocument {
2639    /// Structured document values.
2640    Structured(BTreeMap<String, Value>),
2641    /// Plain text document.
2642    Text(String),
2643}
2644
2645/// The pseudonymized output from `Pipeline::redact`.
2646///
2647/// Mirrors the shape of `RawDocument`: `CleanDocument::Text(String)` or
2648/// `CleanDocument::Structured(BTreeMap<String, Value>)`. Destructure with a `let`-else
2649/// or `match`; **there is no `.text()` accessor**.
2650///
2651/// ```rust
2652/// use gaze_types::CleanDocument;
2653///
2654/// fn unwrap_text(doc: CleanDocument) -> Option<String> {
2655///     if let CleanDocument::Text(t) = doc { Some(t) } else { None }
2656/// }
2657/// ```
2658///
2659/// Contains only tokens or redacted placeholders -- no original PII values.
2660/// Send this (or its inner string) to the LLM; never send the original `RawDocument`.
2661///
2662/// `CleanDocument` is `#[non_exhaustive]`.
2663#[derive(Debug, Clone, Serialize)]
2664#[serde(untagged)]
2665#[non_exhaustive]
2666pub enum CleanDocument {
2667    /// Structured document values.
2668    Structured(BTreeMap<String, Value>),
2669    /// Plain text document.
2670    Text(String),
2671}
2672
2673/// Minimal structured value representation that avoids a serde_json dependency.
2674#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2675#[serde(untagged)]
2676#[non_exhaustive]
2677pub enum Value {
2678    /// Null value.
2679    Null,
2680    /// Boolean value.
2681    Bool(bool),
2682    /// String value.
2683    String(String),
2684    /// Signed 64-bit integer value.
2685    I64(i64),
2686    /// Array value.
2687    Array(Vec<Value>),
2688    /// Object value.
2689    Object(BTreeMap<String, Value>),
2690}
2691
2692impl Value {
2693    /// Returns the inner string for string values.
2694    pub fn as_str(&self) -> Option<&str> {
2695        match self {
2696            Self::String(value) => Some(value.as_str()),
2697            Self::Null | Self::Bool(_) | Self::I64(_) | Self::Array(_) | Self::Object(_) => None,
2698        }
2699    }
2700
2701    /// Returns a scalar string representation used for structured safety-net checks.
2702    pub fn scalar_to_safety_net_string(&self) -> Option<String> {
2703        match self {
2704            Self::String(value) if !value.is_empty() => Some(value.clone()),
2705            Self::String(_) | Self::Null | Self::Array(_) | Self::Object(_) => None,
2706            Self::Bool(value) => Some(value.to_string()),
2707            Self::I64(value) => Some(value.to_string()),
2708        }
2709    }
2710}
2711
2712impl PartialEq<&str> for Value {
2713    fn eq(&self, other: &&str) -> bool {
2714        self.as_str() == Some(*other)
2715    }
2716}
2717
2718/// Value-only dictionary bundle shared with recognizers.
2719#[derive(Debug, Clone, Default)]
2720pub struct DictionaryBundle {
2721    entries: HashMap<String, DictionaryEntry>,
2722}
2723
2724/// Value-only dictionary entry; compiled automatons live outside `gaze-types`.
2725#[derive(Debug, Clone)]
2726pub struct DictionaryEntry {
2727    terms: Vec<String>,
2728    case_sensitive: bool,
2729    source: DictionarySource,
2730}
2731
2732/// Source of a dictionary entry.
2733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2734#[non_exhaustive]
2735pub enum DictionarySource {
2736    /// Dictionary supplied by request context.
2737    Cli,
2738    /// Dictionary supplied by a rulepack.
2739    Rulepack,
2740}
2741
2742/// Dictionary metadata used for diagnostics and tests.
2743#[derive(Debug, Clone, PartialEq, Eq)]
2744#[non_exhaustive]
2745pub struct DictionaryStats {
2746    /// Dictionary name.
2747    pub name: String,
2748    /// Number of configured terms.
2749    pub term_count: usize,
2750    /// Dictionary source.
2751    pub source: DictionarySource,
2752}
2753
2754impl DictionaryStats {
2755    /// Builds dictionary diagnostics metadata.
2756    pub fn new(name: impl Into<String>, term_count: usize, source: DictionarySource) -> Self {
2757        Self {
2758            name: name.into(),
2759            term_count,
2760            source,
2761        }
2762    }
2763}
2764
2765/// Dictionary declared by a rulepack.
2766#[derive(Debug, Clone, PartialEq, Eq)]
2767#[non_exhaustive]
2768pub struct RulepackDict {
2769    /// Dictionary name.
2770    pub name: String,
2771    /// Dictionary terms.
2772    pub terms: Vec<String>,
2773    /// Whether matching is case-sensitive.
2774    pub case_sensitive: bool,
2775}
2776
2777impl RulepackDict {
2778    /// Builds a rulepack dictionary declaration.
2779    pub fn new(name: impl Into<String>, terms: Vec<String>, case_sensitive: bool) -> Self {
2780        Self {
2781            name: name.into(),
2782            terms,
2783            case_sensitive,
2784        }
2785    }
2786}
2787
2788/// Error raised when constructing invalid dictionary entries.
2789#[derive(Debug, Clone, PartialEq, Eq)]
2790#[non_exhaustive]
2791pub enum DictionaryLoadError {
2792    /// Dictionary has no terms.
2793    Empty { name: String },
2794    /// ASCII-only case-insensitive matching cannot safely cover this entry.
2795    UnicodeInsensitiveUnsupported { name: String },
2796}
2797
2798impl fmt::Display for DictionaryLoadError {
2799    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2800        match self {
2801            Self::Empty { name } => write!(f, "dictionary '{name}' has no terms"),
2802            Self::UnicodeInsensitiveUnsupported { name } => write!(
2803                f,
2804                "dictionary '{name}' uses unicode terms with case-insensitive matching, unsupported in v0.4.0; use case_sensitive = true"
2805            ),
2806        }
2807    }
2808}
2809
2810impl std::error::Error for DictionaryLoadError {}
2811
2812impl DictionaryBundle {
2813    /// Builds a bundle from rulepack dictionaries.
2814    pub fn from_rulepack_terms(terms: &[RulepackDict]) -> Self {
2815        let mut entries = HashMap::with_capacity(terms.len());
2816        for dictionary in terms {
2817            let entry = DictionaryEntry::new(
2818                &dictionary.name,
2819                dictionary.terms.clone(),
2820                dictionary.case_sensitive,
2821                DictionarySource::Rulepack,
2822            )
2823            .expect("Policy validates dictionary terms before bundle construction");
2824            entries.insert(dictionary.name.clone(), entry);
2825        }
2826        Self { entries }
2827    }
2828
2829    /// Builds a bundle from pre-built dictionary entries.
2830    pub fn from_entries(entries: impl IntoIterator<Item = (String, DictionaryEntry)>) -> Self {
2831        Self {
2832            entries: entries.into_iter().collect(),
2833        }
2834    }
2835
2836    /// Merges two bundles, preferring entries from the second bundle on name conflicts.
2837    pub fn merge(a: Self, b: Self) -> Self {
2838        let mut entries = a.entries;
2839        entries.extend(b.entries);
2840        Self { entries }
2841    }
2842
2843    /// Returns a dictionary by name.
2844    pub fn get(&self, name: &str) -> Option<&DictionaryEntry> {
2845        self.entries.get(name)
2846    }
2847
2848    /// Returns sorted dictionary stats.
2849    pub fn stats(&self) -> Vec<DictionaryStats> {
2850        let mut stats = self
2851            .entries
2852            .iter()
2853            .map(|(name, entry)| DictionaryStats {
2854                name: name.clone(),
2855                term_count: entry.terms.len(),
2856                source: entry.source,
2857            })
2858            .collect::<Vec<_>>();
2859        stats.sort_by(|a, b| a.name.cmp(&b.name));
2860        stats
2861    }
2862}
2863
2864impl DictionaryEntry {
2865    /// Creates a validated value-only dictionary entry.
2866    pub fn new(
2867        name: &str,
2868        terms: Vec<String>,
2869        case_sensitive: bool,
2870        source: DictionarySource,
2871    ) -> Result<Self, DictionaryLoadError> {
2872        if terms.is_empty() {
2873            return Err(DictionaryLoadError::Empty {
2874                name: name.to_string(),
2875            });
2876        }
2877        if !case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
2878            return Err(DictionaryLoadError::UnicodeInsensitiveUnsupported {
2879                name: name.to_string(),
2880            });
2881        }
2882        Ok(Self {
2883            terms,
2884            case_sensitive,
2885            source,
2886        })
2887    }
2888
2889    /// Returns whether matching is case-sensitive.
2890    pub fn case_sensitive(&self) -> bool {
2891        self.case_sensitive
2892    }
2893
2894    /// Returns configured dictionary terms.
2895    pub fn terms(&self) -> &[String] {
2896        &self.terms
2897    }
2898}
2899
2900#[cfg(test)]
2901mod dictionary_tests {
2902    use super::*;
2903
2904    #[test]
2905    fn dictionary_entry_rejects_empty_terms() {
2906        let err = DictionaryEntry::new("empty", Vec::new(), true, DictionarySource::Cli)
2907            .expect_err("empty dictionaries must fail closed");
2908
2909        assert!(matches!(err, DictionaryLoadError::Empty { name } if name == "empty"));
2910    }
2911
2912    #[test]
2913    fn dictionary_entry_rejects_non_ascii_case_insensitive_terms() {
2914        let err = DictionaryEntry::new(
2915            "songs",
2916            vec!["Beyonce".to_string(), "Caf\u{00e9}".to_string()],
2917            false,
2918            DictionarySource::Cli,
2919        )
2920        .expect_err("unicode case-insensitive dictionaries must fail closed");
2921
2922        assert!(matches!(
2923            err,
2924            DictionaryLoadError::UnicodeInsensitiveUnsupported { name } if name == "songs"
2925        ));
2926    }
2927}
2928
2929#[cfg(test)]
2930mod redaction_logger_tests {
2931    use super::*;
2932
2933    struct CapturingLogger;
2934
2935    impl RedactionLogger for CapturingLogger {
2936        fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
2937            Ok(())
2938        }
2939    }
2940
2941    fn assert_send_sync<T: Send + Sync + ?Sized>() {}
2942
2943    #[test]
2944    fn redaction_log_error_display_is_stable() {
2945        assert_eq!(
2946            RedactionLogError::Sqlite("write failed".to_string()).to_string(),
2947            "sqlite redaction log error: write failed"
2948        );
2949        assert_eq!(
2950            RedactionLogError::Backend("sink failed".to_string()).to_string(),
2951            "backend redaction log error: sink failed"
2952        );
2953    }
2954
2955    #[test]
2956    fn redaction_logger_trait_object_is_send_sync() {
2957        assert_send_sync::<dyn RedactionLogger>();
2958    }
2959
2960    #[test]
2961    fn local_logger_can_implement_redaction_logger() {
2962        let logger = CapturingLogger;
2963        let entry = RedactionEntry {
2964            source: "unit-test".to_string(),
2965            recognizer_id: None,
2966            recognizer_version_id: None,
2967            class: PiiClass::Email,
2968            action: Action::Tokenize,
2969            field_name: None,
2970            document_kind: DocumentKind::Text,
2971            conflict_loser: false,
2972            decided_by: ConflictTier::None,
2973            created_at: 0,
2974            session_id: None,
2975            validator_fail_reason: None,
2976            ambiguity_record: None,
2977            collision_family: None,
2978            collision_variant: None,
2979            fallback_triggered: None,
2980            provenance_stage: None,
2981            provenance_model_id: None,
2982            provenance_model_version: None,
2983            provenance_artifact_sha256: None,
2984            provenance_tokenizer_sha256: None,
2985            provenance_locale_resolved: None,
2986            provenance_locale_match_kind: None,
2987            provenance_canonical_class: None,
2988            provenance_native_class: None,
2989            provenance_confidence: None,
2990            provenance_merged_from: None,
2991            backend_silently_dropped: None,
2992            restore_policy: None,
2993            restore_decision: None,
2994            restore_unknown_token_count: None,
2995            restore_manifest_bypass_count: None,
2996            restore_fresh_pii_count: None,
2997            restore_phase_mask: None,
2998        };
2999
3000        let trait_object: &dyn RedactionLogger = &logger;
3001        trait_object.log(&entry).expect("log entry");
3002    }
3003
3004    #[test]
3005    fn redaction_entry_json_shape_omits_absent_recognizer_lineage() {
3006        let entry = RedactionEntry::new(
3007            "email.global",
3008            PiiClass::Email,
3009            Action::Tokenize,
3010            None,
3011            DocumentKind::Text,
3012            false,
3013            ConflictTier::None,
3014            0,
3015            None,
3016        );
3017
3018        let rendered = serde_json::to_string(&entry).expect("serialize redaction entry");
3019
3020        assert_eq!(
3021            rendered,
3022            r#"{"source":"email.global","class":"email","action":"tokenize","field_name":null,"document_kind":"text","conflict_loser":false,"decided_by":"none","created_at":0,"session_id":null,"validator_fail_reason":null,"ambiguity_record":null,"collision_family":null,"collision_variant":null,"fallback_triggered":null}"#
3023        );
3024    }
3025
3026    #[test]
3027    fn redaction_entry_json_shape_includes_recognizer_lineage_when_present() {
3028        let entry = RedactionEntry::new(
3029            "ner/ort",
3030            PiiClass::Name,
3031            Action::Tokenize,
3032            None,
3033            DocumentKind::Text,
3034            false,
3035            ConflictTier::None,
3036            0,
3037            None,
3038        )
3039        .with_recognizer_metadata(
3040            Some("ner".to_string()),
3041            Some("ner.davlan-mbert.v1".to_string()),
3042        );
3043
3044        let value: serde_json::Value =
3045            serde_json::to_value(&entry).expect("serialize redaction entry");
3046
3047        assert_eq!(value["recognizer_id"], "ner");
3048        assert_eq!(value["recognizer_version_id"], "ner.davlan-mbert.v1");
3049    }
3050
3051    #[test]
3052    fn candidate_keeps_versioned_and_unversioned_recognizer_ids() {
3053        let unversioned = Candidate::new(
3054            0..5,
3055            PiiClass::Email,
3056            "email.global",
3057            0.9,
3058            10,
3059            None,
3060            "email",
3061            "email.global",
3062            ConflictTier::None,
3063            Vec::new(),
3064        );
3065        assert_eq!(unversioned.recognizer_id, "email.global");
3066        assert_eq!(unversioned.recognizer_version_id, None);
3067
3068        let versioned = unversioned
3069            .clone()
3070            .with_recognizer_version_id("email.global.v1");
3071        assert_eq!(versioned.recognizer_id, "email.global");
3072        assert_eq!(
3073            versioned.recognizer_version_id.as_deref(),
3074            Some("email.global.v1")
3075        );
3076    }
3077}
3078
3079#[cfg(test)]
3080mod safety_net_manifest_tests {
3081    use super::*;
3082
3083    fn span(start: usize, end: usize, class: PiiClass) -> EmittedTokenSpan {
3084        EmittedTokenSpan {
3085            clean_span: start..end,
3086            raw_span: start..end,
3087            class,
3088        }
3089    }
3090
3091    fn diff(manifest: Manifest, suspect: Range<usize>, class: PiiClass) -> Option<LeakKind> {
3092        manifest.diff_against(&suspect, &class)
3093    }
3094
3095    #[test]
3096    fn exact_same_class_coverage_is_not_a_leak() {
3097        let manifest = Manifest::from_spans(vec![span(0, 8, PiiClass::Email)]);
3098
3099        assert_eq!(diff(manifest, 0..8, PiiClass::Email), None);
3100    }
3101
3102    #[test]
3103    fn uncovered_outside_all_tokens_is_uncovered() {
3104        let manifest = Manifest::from_spans(vec![span(20, 30, PiiClass::Email)]);
3105
3106        assert_eq!(
3107            diff(manifest, 0..10, PiiClass::Email),
3108            Some(LeakKind::Uncovered)
3109        );
3110    }
3111
3112    #[test]
3113    fn single_internal_gap_returns_partial_bleed() {
3114        let manifest = Manifest::from_spans(vec![
3115            span(0, 5, PiiClass::Email),
3116            span(10, 15, PiiClass::Email),
3117        ]);
3118
3119        assert_eq!(
3120            diff(manifest, 0..15, PiiClass::Email),
3121            Some(LeakKind::PartialBleed { uncovered: 5..10 })
3122        );
3123    }
3124
3125    #[test]
3126    fn multi_gap_returns_deterministic_first_uncovered_gap() {
3127        let manifest = Manifest::from_spans(vec![
3128            span(0, 3, PiiClass::Email),
3129            span(5, 7, PiiClass::Email),
3130            span(9, 12, PiiClass::Email),
3131        ]);
3132
3133        // The first-gap-only rule is intentional for v0.6.1; full gap
3134        // enumeration is deferred until the report format can carry it.
3135        assert_eq!(
3136            diff(manifest, 0..12, PiiClass::Email),
3137            Some(LeakKind::PartialBleed { uncovered: 3..5 })
3138        );
3139    }
3140
3141    #[test]
3142    fn multi_class_overlap_reports_first_mismatch_deterministically() {
3143        let manifest = Manifest::from_spans(vec![
3144            span(0, 4, PiiClass::Name),
3145            span(4, 8, PiiClass::Location),
3146        ]);
3147
3148        assert_eq!(
3149            diff(manifest, 0..8, PiiClass::Email),
3150            Some(LeakKind::ClassMismatch {
3151                pipeline_class: PiiClass::Name,
3152                safety_net_class: PiiClass::Email,
3153            })
3154        );
3155    }
3156
3157    #[test]
3158    fn adjacent_same_class_tokens_cover_continuously() {
3159        let manifest = Manifest::from_spans(vec![
3160            span(0, 5, PiiClass::Email),
3161            span(5, 10, PiiClass::Email),
3162        ]);
3163
3164        assert_eq!(diff(manifest, 0..10, PiiClass::Email), None);
3165    }
3166
3167    #[test]
3168    fn partial_bleed_at_start_end_and_middle() {
3169        let manifest = Manifest::from_spans(vec![span(3, 8, PiiClass::Email)]);
3170
3171        assert_eq!(
3172            diff(manifest.clone(), 0..8, PiiClass::Email),
3173            Some(LeakKind::PartialBleed { uncovered: 0..3 })
3174        );
3175        assert_eq!(
3176            diff(manifest.clone(), 3..10, PiiClass::Email),
3177            Some(LeakKind::PartialBleed { uncovered: 8..10 })
3178        );
3179
3180        let with_gap = Manifest::from_spans(vec![
3181            span(0, 3, PiiClass::Email),
3182            span(6, 10, PiiClass::Email),
3183        ]);
3184        assert_eq!(
3185            diff(with_gap, 0..10, PiiClass::Email),
3186            Some(LeakKind::PartialBleed { uncovered: 3..6 })
3187        );
3188    }
3189
3190    #[test]
3191    fn byte_indices_are_not_character_indices() {
3192        let text = "ID: 😀 <Email_1>";
3193        let token_start = text.find("<Email_1>").expect("token start");
3194        assert_eq!(token_start, 9, "emoji is four bytes, not one char");
3195        let manifest = Manifest::from_spans(vec![span(token_start, text.len(), PiiClass::Email)]);
3196
3197        assert_eq!(
3198            diff(manifest, token_start..text.len(), PiiClass::Email),
3199            None
3200        );
3201    }
3202
3203    #[test]
3204    fn empty_suspect_range_is_not_a_leak() {
3205        let manifest = Manifest::default();
3206
3207        assert_eq!(diff(manifest, 3..3, PiiClass::Email), None);
3208    }
3209
3210    #[test]
3211    fn safety_net_error_display_is_variant_specific_and_bytes_free() {
3212        let cases = [
3213            SafetyNetError::Unavailable {
3214                reason: "not configured".to_string(),
3215            }
3216            .to_string(),
3217            SafetyNetError::WeightsMissing {
3218                path: "/models/opf".to_string(),
3219            }
3220            .to_string(),
3221            SafetyNetError::ModelUnavailable {
3222                reason: "load failed".to_string(),
3223            }
3224            .to_string(),
3225            SafetyNetError::ModelIntegrityMismatch {
3226                expected: "e3b0c44298fc1c149afbf4c8996fb924".to_string(),
3227                actual: "4e07408562bedb8b60ce05c1decfe3ad".to_string(),
3228            }
3229            .to_string(),
3230            SafetyNetError::InputTooLarge {
3231                limit: 1024,
3232                actual: 2048,
3233            }
3234            .to_string(),
3235            SafetyNetError::Runtime {
3236                message: "timeout".to_string(),
3237            }
3238            .to_string(),
3239            SafetyNetError::InvalidOutput {
3240                message: "bad json".to_string(),
3241            }
3242            .to_string(),
3243        ];
3244
3245        for rendered in cases {
3246            assert!(!rendered.contains("alice@example.invalid"));
3247        }
3248    }
3249}
3250
3251/// Shared recognizer contract for locale-aware PII candidates.
3252pub trait Recognizer: Send + Sync {
3253    /// Stable recognizer identifier.
3254    fn id(&self) -> &str;
3255    /// PII class supported by this recognizer.
3256    fn supported_class(&self) -> &PiiClass;
3257    /// Detects PII candidates in the supplied input and context.
3258    fn detect(
3259        &self,
3260        input: &str,
3261        ctx: &DetectContext<'_>,
3262    ) -> std::result::Result<Vec<Candidate>, DetectError>;
3263    /// Token family used for candidate token emission.
3264    fn token_family(&self) -> &str;
3265    /// Optional validator kind used by pre-resolver validator-veto.
3266    fn validator_kind(&self) -> Option<ValidatorKind> {
3267        None
3268    }
3269    /// Locales where this recognizer is active.
3270    fn locales(&self) -> &[LocaleTag] {
3271        &[LocaleTag::Global]
3272    }
3273}
3274
3275/// Caller-visible recognizer detection failure.
3276#[derive(Debug, Clone, PartialEq, Eq, Error)]
3277#[non_exhaustive]
3278pub enum DetectError {
3279    /// A recognizer backend failed while scanning the input.
3280    #[error("recognizer {recognizer_id} backend failed: {message}")]
3281    Backend {
3282        /// Stable recognizer identifier.
3283        recognizer_id: String,
3284        /// Sanitized backend error message.
3285        message: String,
3286    },
3287}
3288
3289impl DetectError {
3290    /// Build a backend failure without requiring recognizer crates in `gaze-types`.
3291    pub fn backend(recognizer_id: impl Into<String>, source: impl Into<String>) -> Self {
3292        Self::Backend {
3293            recognizer_id: recognizer_id.into(),
3294            message: source.into(),
3295        }
3296    }
3297}
3298
3299/// Candidate PII span emitted by a recognizer before final conflict resolution.
3300#[derive(Debug, Clone, PartialEq)]
3301#[non_exhaustive]
3302pub struct Candidate {
3303    /// Byte span in the original input.
3304    pub span: Range<usize>,
3305    /// PII class assigned to the span.
3306    pub class: PiiClass,
3307    /// Recognizer identifier.
3308    pub recognizer_id: String,
3309    /// Optional versioned recognizer identifier for audit lineage.
3310    pub recognizer_version_id: Option<String>,
3311    /// Recognizer confidence score.
3312    pub score: f32,
3313    /// Rule or recognizer priority.
3314    pub priority: i32,
3315    /// Optional canonical representation for validation/merge logic.
3316    pub canonical_form: Option<String>,
3317    /// Token family used for output token shape.
3318    pub token_family: String,
3319    /// Candidate source label.
3320    pub source: String,
3321    /// Conflict tier that decided this candidate.
3322    pub decided_by: ConflictTier,
3323    /// Sources merged into this candidate.
3324    pub merged_sources: Vec<String>,
3325}
3326
3327impl Candidate {
3328    /// Builds a recognizer candidate.
3329    #[allow(clippy::too_many_arguments)]
3330    pub fn new(
3331        span: Range<usize>,
3332        class: PiiClass,
3333        recognizer_id: impl Into<String>,
3334        score: f32,
3335        priority: i32,
3336        canonical_form: Option<String>,
3337        token_family: impl Into<String>,
3338        source: impl Into<String>,
3339        decided_by: ConflictTier,
3340        merged_sources: Vec<String>,
3341    ) -> Self {
3342        Self {
3343            span,
3344            class,
3345            recognizer_id: recognizer_id.into(),
3346            recognizer_version_id: None,
3347            score,
3348            priority,
3349            canonical_form,
3350            token_family: token_family.into(),
3351            source: source.into(),
3352            decided_by,
3353            merged_sources,
3354        }
3355    }
3356
3357    /// Returns this candidate with a translated span.
3358    pub fn with_span(mut self, span: Range<usize>) -> Self {
3359        self.span = span;
3360        self
3361    }
3362
3363    /// Returns this candidate with versioned recognizer lineage attached.
3364    pub fn with_recognizer_version_id(mut self, recognizer_version_id: impl Into<String>) -> Self {
3365        self.recognizer_version_id = Some(recognizer_version_id.into());
3366        self
3367    }
3368}
3369
3370/// Context supplied to recognizers during detection.
3371#[non_exhaustive]
3372pub struct DetectContext<'a> {
3373    /// Active locale chain.
3374    pub locale_chain: &'a [LocaleTag],
3375    /// Active dictionary bundle.
3376    pub dictionaries: &'a DictionaryBundle,
3377    /// Reserved field-aware matching slot; intentionally unit in v0.5 Phase B.
3378    pub fields: &'a (),
3379    /// Whether a recognizer degraded due to unavailable optional capability.
3380    pub degraded: Cell<bool>,
3381}
3382
3383impl<'a> DetectContext<'a> {
3384    /// Builds detection context for a recognizer pass.
3385    pub fn new(locale_chain: &'a [LocaleTag], dictionaries: &'a DictionaryBundle) -> Self {
3386        Self {
3387            locale_chain,
3388            dictionaries,
3389            fields: &(),
3390            degraded: Cell::new(false),
3391        }
3392    }
3393}
3394
3395fn ensure_global(tags: &mut Vec<LocaleTag>) {
3396    if !tags.contains(&LocaleTag::Global) {
3397        tags.push(LocaleTag::Global);
3398    }
3399}
3400
3401fn is_bcp47_parseable(raw: &str) -> bool {
3402    let mut parts = raw.split('-');
3403    let Some(language) = parts.next() else {
3404        return false;
3405    };
3406    if !(2..=8).contains(&language.len()) || !language.chars().all(|ch| ch.is_ascii_alphabetic()) {
3407        return false;
3408    }
3409    parts.all(|part| {
3410        (2..=8).contains(&part.len()) && part.chars().all(|ch| ch.is_ascii_alphanumeric())
3411    })
3412}
3413
3414fn canonical_other(raw: &str) -> String {
3415    let mut parts = raw.split('-');
3416    let language = parts.next().unwrap_or_default().to_ascii_lowercase();
3417    let rest = parts.map(|part| {
3418        if part.len() == 2 && part.chars().all(|ch| ch.is_ascii_alphabetic()) {
3419            part.to_ascii_uppercase()
3420        } else {
3421            part.to_ascii_lowercase()
3422        }
3423    });
3424    std::iter::once(language)
3425        .chain(rest)
3426        .collect::<Vec<_>>()
3427        .join("-")
3428}