Skip to main content

gaze_types/
lib.rs

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