Skip to main content

gaze_types/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3pub mod inspection;
4pub mod nym;
5pub mod payment_card;
6pub mod redaction_marker;
7
8use std::cell::Cell;
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fmt;
11use std::ops::Range;
12
13use serde::{Deserialize, Serialize};
14use sha3::{Digest, Keccak256};
15use thiserror::Error;
16
17/// What a detection rests on, declared by the recognizer or detector that emits it.
18///
19/// The repeat-value sweep copies only [`EvidenceKind::Rule`] values to every other occurrence
20/// in the document and in later turns of the session, so a model's mistake is never spread.
21/// The default is [`EvidenceKind::Learned`]: an emitter that declares nothing is never
22/// propagated. Declare `Rule` only when every candidate comes from a deterministic rule a
23/// reviewer can read (a regex, a dictionary term, a cue-anchored match).
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum EvidenceKind {
26    /// A model or heuristic decided: NER, a model judge, a span licensed by a model span.
27    #[default]
28    Learned,
29    /// A deterministic rule decided.
30    Rule,
31}
32
33/// Matching variants available to a caller-known record. Unmeasured variants
34/// require an explicit adopter opt-in in the context envelope.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RecordMatchKind {
38    Exact,
39    WhitespaceFlexible,
40    CaseFolded,
41    WhitespaceCaseFolded,
42    CorroboratedSingle,
43}
44
45/// Shared detector contract for text-only PII detection.
46pub trait Detector: Send + Sync {
47    /// Detect PII spans in the supplied input string.
48    fn detect(&self, input: &str) -> Vec<Detection>;
49
50    /// What this detector's detections rest on. Defaults to [`EvidenceKind::Learned`], which
51    /// the repeat-value sweep never propagates; see [`EvidenceKind`].
52    fn evidence(&self) -> EvidenceKind {
53        EvidenceKind::Learned
54    }
55
56    /// Fallible detection entrypoint for detectors backed by runtime systems.
57    fn try_detect(&self, input: &str) -> Result<Vec<Detection>, RecognizerRuntimeError> {
58        Ok(self.detect(input))
59    }
60}
61
62/// Runtime failure raised by a recognizer or detector backend during detection.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct RecognizerRuntimeError {
66    pub recognizer_id: String,
67    pub message: String,
68}
69
70impl RecognizerRuntimeError {
71    pub fn new(recognizer_id: impl Into<String>, message: impl Into<String>) -> Self {
72        Self {
73            recognizer_id: recognizer_id.into(),
74            message: message.into(),
75        }
76    }
77}
78
79impl fmt::Display for RecognizerRuntimeError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(
82            f,
83            "recognizer '{}' backend failed: {}",
84            self.recognizer_id, self.message
85        )
86    }
87}
88
89impl std::error::Error for RecognizerRuntimeError {}
90
91/// The category of a detected PII span.
92///
93/// Built-in variants: `Email`, `Name`, `Location`, `Organization`. Tenant-specific PII
94/// (case references, titles, internal codes) is carried as `PiiClass::Custom(String)`.
95/// **There is no `Phone` variant** -- phone detection is provided by recognizers in
96/// `gaze-recognizers` and surfaces as either a `Custom("phone")` class or a class
97/// defined by a rulepack.
98///
99/// `PiiClass` is exhaustive. Match every variant explicitly so new built-in classes
100/// force call sites to review their handling at compile time:
101///
102/// ```rust
103/// use gaze_types::PiiClass;
104///
105/// fn label(class: &PiiClass) -> &'static str {
106///     match class {
107///         PiiClass::Email        => "email",
108///         PiiClass::Name         => "name",
109///         PiiClass::Location     => "location",
110///         PiiClass::Organization => "org",
111///         PiiClass::Custom(_)    => "pii",
112///     }
113/// }
114/// ```
115///
116/// Policy TOML uses the lowercase forms `email` / `name` / `location` / `organization`,
117/// and tenant classes are spelled like `custom:case_ref` (lowercase, snake_case).
118#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
119pub enum PiiClass {
120    /// Email address class.
121    Email,
122    /// Person name class.
123    Name,
124    /// Location class.
125    Location,
126    /// Organization class.
127    Organization,
128    /// Tenant- or policy-defined class.
129    Custom(String),
130}
131
132/// A custom class must contain at least one ASCII letter or digit.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
134#[error("custom class name normalizes to empty")]
135pub struct EmptyCustomClassName;
136
137/// Recognizer id of the bundled NER recognizer. Pipeline stages that treat NER evidence
138/// specially (street-corroborated house numbers) match on this, so a rename cannot silently
139/// switch them off.
140pub const NER_RECOGNIZER_ID: &str = "ner";
141
142/// Built-in class labels in stable display order.
143pub const BUILTIN_CLASS_NAMES: &[&str] = &["Email", "Name", "Location", "Organization"];
144
145/// Family names reserved for bundled collision-policy rulepacks.
146///
147/// Adopter policy-level custom recognizers cannot claim these names because bundled
148/// families are part of the core disambiguation contract.
149pub const RESERVED_BUNDLED_FAMILIES: &[&str] = &[
150    "us-9-digit-id",
151    "iberian-id",
152    "payment-card-or-iban",
153    "phone-or-imei",
154    "vin-or-serial",
155    "mac-or-hex",
156    "passport-or-doc-support",
157    "national-13-digit",
158    "italian-cf-or-serial",
159    "german-personalausweis",
160    "swedish-personnummer",
161    "finnish-hetu",
162];
163
164pub const RESTORE_PHASE_MANIFEST_LOOKUP: u32 = 1 << 0;
165pub const RESTORE_PHASE_UNKNOWN_TOKEN_SCAN: u32 = 1 << 1;
166pub const RESTORE_PHASE_MANIFEST_BYPASS_SCAN: u32 = 1 << 2;
167pub const RESTORE_PHASE_FRESH_PII_SCAN: u32 = 1 << 3;
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[non_exhaustive]
171pub struct RestoredText {
172    pub text: String,
173}
174
175impl RestoredText {
176    pub fn new(text: impl Into<String>) -> Self {
177        Self { text: text.into() }
178    }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183#[non_exhaustive]
184pub enum RestorePolicy {
185    Strict,
186    Lenient,
187}
188
189impl RestorePolicy {
190    pub fn as_str(self) -> &'static str {
191        match self {
192            Self::Strict => "strict",
193            Self::Lenient => "lenient",
194        }
195    }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200#[non_exhaustive]
201pub enum RestoreDecision {
202    Success,
203    Partial,
204    Failed,
205}
206
207impl RestoreDecision {
208    pub fn as_str(self) -> &'static str {
209        match self {
210            Self::Success => "success",
211            Self::Partial => "partial",
212            Self::Failed => "failed",
213        }
214    }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct RestoreTelemetry {
220    /// Unmapped canonical placeholders or incomplete prefixed wrappers outside authorized output.
221    pub unknown_token_count: u64,
222    /// Audit-only broad bare identifier shapes outside authorized restore output.
223    pub manifest_bypass_count: u64,
224    /// All unprefixed trap shapes in restored text, including authorized values.
225    #[serde(default)]
226    pub trap_shape_count: u64,
227    pub fresh_pii_detected_count: u64,
228    pub restore_policy: RestorePolicy,
229    pub restore_decision: RestoreDecision,
230    pub phase_execution_mask: u32,
231}
232
233impl RestoreTelemetry {
234    pub fn new(restore_policy: RestorePolicy) -> Self {
235        Self {
236            unknown_token_count: 0,
237            manifest_bypass_count: 0,
238            trap_shape_count: 0,
239            fresh_pii_detected_count: 0,
240            restore_policy,
241            restore_decision: RestoreDecision::Success,
242            phase_execution_mask: 0,
243        }
244    }
245
246    pub fn restore_policy_str(&self) -> &'static str {
247        self.restore_policy.as_str()
248    }
249
250    pub fn restore_decision_str(&self) -> &'static str {
251        self.restore_decision.as_str()
252    }
253}
254
255/// Collision-family membership metadata for one recognizer.
256#[derive(Debug, Clone, PartialEq, Eq)]
257#[non_exhaustive]
258pub struct CollisionMembership {
259    /// Cross-class family name.
260    pub family: String,
261    /// Variant name within the family.
262    pub variant: String,
263    /// Lower values win when two variants in the same family overlap.
264    pub precedence: u32,
265    /// Optional anchor variant required by later ambiguity handling.
266    pub mandatory_anchor: Option<String>,
267}
268
269impl CollisionMembership {
270    /// Builds collision-family membership metadata.
271    pub fn new(
272        family: impl Into<String>,
273        variant: impl Into<String>,
274        precedence: u32,
275        mandatory_anchor: Option<String>,
276    ) -> Self {
277        Self {
278            family: family.into(),
279            variant: variant.into(),
280            precedence,
281            mandatory_anchor,
282        }
283    }
284}
285
286impl PiiClass {
287    /// Parses a policy class name into the shared class vocabulary.
288    pub fn from_policy_name(input: &str) -> Option<Self> {
289        let trimmed = input.trim();
290        match trimmed.to_ascii_lowercase().as_str() {
291            "email" => Some(Self::Email),
292            "name" => Some(Self::Name),
293            "location" => Some(Self::Location),
294            "organization" => Some(Self::Organization),
295            _ => trimmed
296                .split_once(':')
297                .filter(|(namespace, _)| namespace.eq_ignore_ascii_case("custom"))
298                .and_then(|(_, name)| Self::from_custom_policy_suffix(name)),
299        }
300    }
301
302    fn from_custom_policy_suffix(name: &str) -> Option<Self> {
303        let name = name.trim();
304        if let Some(family) = name.strip_prefix("family:") {
305            (!family.trim().is_empty()).then(|| Self::family(family))
306        } else {
307            Self::custom(name).ok()
308        }
309    }
310
311    /// Returns the built-in class variants.
312    pub fn builtin_variants() -> &'static [PiiClass] {
313        &[
314            PiiClass::Email,
315            PiiClass::Name,
316            PiiClass::Location,
317            PiiClass::Organization,
318        ]
319    }
320
321    /// Builds a normalized custom class name.
322    ///
323    /// Returns [`EmptyCustomClassName`] when no ASCII letter or digit remains.
324    pub fn custom(name: &str) -> Result<Self, EmptyCustomClassName> {
325        let mut normalized = String::new();
326        let mut pending_underscore = false;
327        for ch in name.trim().chars() {
328            if ch.is_ascii_alphanumeric() {
329                if pending_underscore && !normalized.is_empty() {
330                    normalized.push('_');
331                }
332                normalized.push(ch.to_ascii_lowercase());
333                pending_underscore = false;
334            } else {
335                pending_underscore = true;
336            }
337        }
338
339        let class = Self::Custom(normalized);
340        class.validate_custom_name()?;
341        Ok(class)
342    }
343
344    /// Checks names supplied through the public enum before token emission.
345    pub fn validate_custom_name(&self) -> Result<(), EmptyCustomClassName> {
346        if matches!(self, Self::Custom(name) if !name.chars().any(|ch| ch.is_ascii_alphanumeric()))
347        {
348            return Err(EmptyCustomClassName);
349        }
350        Ok(())
351    }
352
353    /// Builds a collision-family class without normalizing its reserved namespace.
354    pub fn family(name: &str) -> Self {
355        Self::Custom(format!("family:{}", name.trim()))
356    }
357
358    /// Returns the normalized custom class name for custom classes.
359    pub fn as_custom_name(&self) -> Option<&str> {
360        match self {
361            Self::Custom(name) => Some(name.as_str()),
362            Self::Email | Self::Name | Self::Location | Self::Organization => None,
363        }
364    }
365
366    /// Returns the collision-family name for family classes.
367    pub fn as_family_name(&self) -> Option<&str> {
368        self.as_custom_name()?.strip_prefix("family:")
369    }
370
371    /// Returns the audit/token display label for this class.
372    pub fn class_name(&self) -> String {
373        match self {
374            Self::Email => BUILTIN_CLASS_NAMES[0].to_string(),
375            Self::Name => BUILTIN_CLASS_NAMES[1].to_string(),
376            Self::Location => BUILTIN_CLASS_NAMES[2].to_string(),
377            Self::Organization => BUILTIN_CLASS_NAMES[3].to_string(),
378            Self::Custom(name) => format!("Custom:{name}"),
379        }
380    }
381
382    /// Returns the canonical audit/serde label for this class.
383    pub fn to_canonical_str(&self) -> String {
384        match self {
385            Self::Email => "email".to_string(),
386            Self::Name => "name".to_string(),
387            Self::Location => "location".to_string(),
388            Self::Organization => "organization".to_string(),
389            Self::Custom(name) => format!("custom:{name}"),
390        }
391    }
392
393    /// Parses the canonical audit/serde label for a PII class.
394    pub fn from_canonical_str(value: &str) -> Option<Self> {
395        match value {
396            "email" | "Email" => Some(Self::Email),
397            "name" | "Name" => Some(Self::Name),
398            "location" | "Location" => Some(Self::Location),
399            "organization" | "Organization" => Some(Self::Organization),
400            custom if custom.starts_with("custom:") => {
401                let name = &custom["custom:".len()..];
402                (!name.is_empty()).then(|| Self::Custom(name.to_string()))
403            }
404            _ => None,
405        }
406    }
407}
408
409#[cfg(test)]
410mod pii_class_tests {
411    use super::*;
412
413    #[test]
414    fn bundled_family_policy_names_round_trip_without_normalization() {
415        for family in RESERVED_BUNDLED_FAMILIES {
416            let policy_name = format!("custom:family:{family}");
417            let class = PiiClass::from_policy_name(&policy_name).expect("family policy class");
418
419            assert_eq!(class.to_canonical_str(), policy_name);
420            assert_eq!(class.as_family_name(), Some(*family));
421        }
422    }
423}
424
425/// Audit-canonical form of [`PiiClass`].
426///
427/// Serializes as `"email"`, `"name"`, `"custom:foo"`, and similar canonical
428/// strings. Use this wrapper for audit-row JSON only. Session snapshots use
429/// bare [`PiiClass`] serde so their byte shape stays stable.
430#[derive(Debug, Clone, PartialEq, Eq)]
431#[non_exhaustive]
432pub struct PiiClassAudit(pub PiiClass);
433
434impl PiiClassAudit {
435    /// Builds an audit-canonical class wrapper.
436    pub fn new(class: PiiClass) -> Self {
437        Self(class)
438    }
439
440    /// Unwraps the underlying class.
441    pub fn into_inner(self) -> PiiClass {
442        self.0
443    }
444}
445
446impl Serialize for PiiClassAudit {
447    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
448    where
449        S: serde::Serializer,
450    {
451        serializer.serialize_str(&self.0.to_canonical_str())
452    }
453}
454
455impl<'de> Deserialize<'de> for PiiClassAudit {
456    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
457    where
458        D: serde::Deserializer<'de>,
459    {
460        let value = String::deserialize(deserializer)?;
461        PiiClass::from_canonical_str(&value)
462            .map(Self)
463            .ok_or_else(|| {
464                serde::de::Error::custom(format!("unknown PiiClass canonical form: {value}"))
465            })
466    }
467}
468
469mod pii_class_audit_serde {
470    use super::{PiiClass, PiiClassAudit};
471    use serde::{Deserialize, Deserializer, Serialize, Serializer};
472
473    pub fn serialize<S>(class: &PiiClass, serializer: S) -> Result<S::Ok, S::Error>
474    where
475        S: Serializer,
476    {
477        PiiClassAudit::new(class.clone()).serialize(serializer)
478    }
479
480    pub fn deserialize<'de, D>(deserializer: D) -> Result<PiiClass, D::Error>
481    where
482        D: Deserializer<'de>,
483    {
484        Ok(PiiClassAudit::deserialize(deserializer)?.into_inner())
485    }
486}
487
488/// A candidate recognizer/class pair that lost ambiguity resolution.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490#[non_exhaustive]
491pub struct LosingCandidate {
492    /// PII class proposed by the losing recognizer.
493    #[serde(with = "pii_class_audit_serde")]
494    pub class: PiiClass,
495    /// Stable recognizer identifier for traceability.
496    pub recognizer_id: String,
497}
498
499impl LosingCandidate {
500    /// Builds a losing ambiguity candidate.
501    pub fn new(class: PiiClass, recognizer_id: impl Into<String>) -> Self {
502        Self {
503            class,
504            recognizer_id: recognizer_id.into(),
505        }
506    }
507}
508
509/// Where a family-level token's policy action came from when no rule named
510/// the family class: the strictest action among the family's member classes'
511/// rules and the family's own default, see [`Action::strictness_rank`].
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[non_exhaustive]
514pub struct DerivedFamilyAction {
515    /// The action applied to the family token.
516    pub action: Action,
517    /// The member class whose explicit rule set the action. `None` when the
518    /// family's own default rule applied: no member's own rule reached the
519    /// derived strictness. Ties between members go to the lowest class in
520    /// `PiiClass` order.
521    #[serde(with = "optional_pii_class_audit_serde", default)]
522    pub member_class: Option<PiiClass>,
523}
524
525impl DerivedFamilyAction {
526    /// Records a family action derived from `member_class` (or the default).
527    pub fn new(action: Action, member_class: Option<PiiClass>) -> Self {
528        Self {
529            action,
530            member_class,
531        }
532    }
533}
534
535mod optional_pii_class_audit_serde {
536    use super::{PiiClass, PiiClassAudit};
537    use serde::{Deserialize, Deserializer, Serialize, Serializer};
538
539    pub fn serialize<S>(class: &Option<PiiClass>, serializer: S) -> Result<S::Ok, S::Error>
540    where
541        S: Serializer,
542    {
543        class.clone().map(PiiClassAudit::new).serialize(serializer)
544    }
545
546    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<PiiClass>, D::Error>
547    where
548        D: Deserializer<'de>,
549    {
550        Ok(Option::<PiiClassAudit>::deserialize(deserializer)?.map(PiiClassAudit::into_inner))
551    }
552}
553
554/// Structured metadata describing an ambiguity outcome.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[non_exhaustive]
557pub struct AmbiguityRecord {
558    /// The family-level class assigned when disambiguation failed.
559    #[serde(with = "pii_class_audit_serde")]
560    pub ambiguity_class: PiiClass,
561    /// Variants that could not be disambiguated.
562    ///
563    /// Producers must keep this list stable by sorting `recognizer_id` ascending.
564    pub losing_candidates: Vec<LosingCandidate>,
565    /// Why disambiguation failed.
566    pub reason: AmbiguityReason,
567    /// How the family token's action was chosen when the policy named no rule
568    /// for the family class. Absent when an explicit family rule applied, and
569    /// on rows written before this field existed.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub derived_action: Option<DerivedFamilyAction>,
572}
573
574impl AmbiguityRecord {
575    /// Builds a structured ambiguity record.
576    pub fn new(
577        ambiguity_class: PiiClass,
578        losing_candidates: Vec<LosingCandidate>,
579        reason: AmbiguityReason,
580    ) -> Self {
581        Self {
582            ambiguity_class,
583            losing_candidates,
584            reason,
585            derived_action: None,
586        }
587    }
588
589    /// Records how the family token's action was derived.
590    pub fn with_derived_action(mut self, derived: DerivedFamilyAction) -> Self {
591        self.derived_action = Some(derived);
592        self
593    }
594}
595
596/// Closed set of ambiguity outcomes recorded by the audit side-channel.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
598#[non_exhaustive]
599#[serde(rename_all = "snake_case")]
600pub enum AmbiguityReason {
601    /// Span matched a multi-recognizer family and no anchor cue resolved it.
602    NoAnchor,
603    /// Multiple validator-stage recognizers remained viable for the same span.
604    ValidatorIndeterminate,
605    /// Span matched recognizers across two or more distinct PII class families.
606    MultiFamilyMatch,
607    /// Multiple variants had the same precedence and no discriminator resolved them.
608    PrecedenceTie,
609}
610
611/// Closed pre-resolution refusal reasons recorded by audit metadata.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613#[non_exhaustive]
614#[serde(rename_all = "snake_case")]
615pub enum ValidatorFailReason {
616    /// A rulepack rejection guard refused the original regex capture.
617    RegexGuardRejected,
618    /// Luhn checksum validation failed.
619    LuhnFailed,
620    /// IBAN MOD-97 validation failed.
621    IbanMod97Failed,
622    /// Email RFC-style validation failed.
623    #[serde(alias = "email_rfc_failed")]
624    EmailRfcRejected,
625    /// E.164 phone validation failed.
626    #[serde(alias = "e164_phone_failed")]
627    PhoneE164Rejected,
628    /// National phone parser accepted the number but region validation failed.
629    PhoneNationalRegionMismatch,
630    /// IPv4 parser rejected the candidate.
631    Ipv4ParseFailed,
632    /// IPv6 parser rejected the candidate.
633    Ipv6ParseFailed,
634    /// Built-in IPv4 rule excluded an RFC 5737 documentation address.
635    Ipv4DocumentationRange,
636    /// Built-in IPv6 rule excluded an RFC 3849 or embedded RFC 5737 address.
637    Ipv6DocumentationRange,
638    /// EIP-55 Ethereum checksum validation failed.
639    EthEip55ChecksumFailed,
640    /// Aadhaar Verhoeff checksum validation failed.
641    AadhaarVerhoeffFailed,
642    /// French NIR MOD-97 key validation failed.
643    FrNirMod97Failed,
644    /// German Steuer-ID MOD 11,10 checksum validation failed.
645    DeSteuerIdMod1110Failed,
646    /// Dutch BSN MOD-11 checksum validation failed.
647    BsnMod11Failed,
648    /// Brazilian CPF MOD-11 checksum validation failed.
649    CpfMod11Failed,
650    /// Brazilian CNPJ MOD-11 checksum validation failed.
651    CnpjMod11Failed,
652    /// UK NHS number MOD-11 checksum validation failed.
653    UkNhsMod11Failed,
654    /// Built-in IPv4 rule excluded a loopback address (127.0.0.0/8).
655    Ipv4LoopbackRange,
656    /// Built-in IPv6 rule excluded a loopback address (`::1`, or an IPv4-mapped or IPv4-compatible
657    /// 127.0.0.0/8 address).
658    Ipv6LoopbackRange,
659}
660
661impl ValidatorFailReason {
662    /// Every variant, for closed-vocabulary checks (component ledger records).
663    #[doc(hidden)]
664    pub const ALL: &'static [Self] = &[
665        Self::RegexGuardRejected,
666        Self::LuhnFailed,
667        Self::IbanMod97Failed,
668        Self::EmailRfcRejected,
669        Self::PhoneE164Rejected,
670        Self::PhoneNationalRegionMismatch,
671        Self::Ipv4ParseFailed,
672        Self::Ipv6ParseFailed,
673        Self::Ipv4DocumentationRange,
674        Self::Ipv6DocumentationRange,
675        Self::EthEip55ChecksumFailed,
676        Self::AadhaarVerhoeffFailed,
677        Self::FrNirMod97Failed,
678        Self::DeSteuerIdMod1110Failed,
679        Self::BsnMod11Failed,
680        Self::CpfMod11Failed,
681        Self::CnpjMod11Failed,
682        Self::UkNhsMod11Failed,
683        Self::Ipv4LoopbackRange,
684        Self::Ipv6LoopbackRange,
685    ];
686
687    /// Fails to compile when a variant is added without extending [`Self::ALL`].
688    #[allow(dead_code)]
689    const fn all_is_exhaustive(value: Self) {
690        match value {
691            Self::RegexGuardRejected
692            | Self::LuhnFailed
693            | Self::IbanMod97Failed
694            | Self::EmailRfcRejected
695            | Self::PhoneE164Rejected
696            | Self::PhoneNationalRegionMismatch
697            | Self::Ipv4ParseFailed
698            | Self::Ipv6ParseFailed
699            | Self::Ipv4DocumentationRange
700            | Self::Ipv6DocumentationRange
701            | Self::EthEip55ChecksumFailed
702            | Self::AadhaarVerhoeffFailed
703            | Self::FrNirMod97Failed
704            | Self::DeSteuerIdMod1110Failed
705            | Self::BsnMod11Failed
706            | Self::CpfMod11Failed
707            | Self::CnpjMod11Failed
708            | Self::UkNhsMod11Failed
709            | Self::Ipv4LoopbackRange
710            | Self::Ipv6LoopbackRange => {}
711        }
712    }
713}
714
715/// Field separators that the bundled labelled identifier rules must all accept.
716pub const LABELLED_FIELD_CONNECTORS: &[char] = &[':', '=', '|', '\t', ',', ';', '-'];
717
718/// Why a labelled identifier scan stopped or exceeded its usual precision bound.
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[non_exhaustive]
721#[serde(rename_all = "snake_case")]
722pub enum LabelledValueScanReason {
723    /// The complete value run exceeded four groups or forty bytes.
724    LimitExceeded,
725    /// A date starts the next field after the captured identifier.
726    DateBoundary,
727    /// A labelled field starts after the captured identifier.
728    LabelBoundary,
729    /// A different recognizer claims the following value.
730    OtherClassBoundary,
731}
732
733/// Typed validator outcome used by the pre-resolver validator-veto phase.
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735#[non_exhaustive]
736#[serde(rename_all = "snake_case")]
737pub enum ValidatorOutcome {
738    /// Candidate passed validation; canonical form may be supplied by the validator.
739    Pass { canonical_form: Option<String> },
740    /// Candidate failed validation with a closed, auditable reason.
741    Fail { reason: ValidatorFailReason },
742    /// Recognizer has no validator for this candidate.
743    NotApplicable,
744}
745
746/// Error returned when a rulepack names a validator unsupported by this build.
747#[derive(Debug, Clone, PartialEq, Eq, Error)]
748#[non_exhaustive]
749pub enum ValidatorKindParseError {
750    /// Validator kind is not known or is gated behind a disabled feature.
751    #[error("unsupported validator: {kind}")]
752    UnsupportedValidator {
753        /// Unsupported validator kind.
754        kind: String,
755    },
756}
757
758/// Closed set of validator implementations used by validator-backed recognizers.
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760#[non_exhaustive]
761pub enum ValidatorKind {
762    /// Basic email shape validator.
763    EmailRfc,
764    /// Parser-backed E.164 phone validator.
765    #[cfg(feature = "phone-parser")]
766    E164Phone,
767    /// Parser-backed national phone validator for a fixed region.
768    #[cfg(feature = "phone-parser")]
769    E164PhoneNational(Region),
770    /// Parser-backed international and national phone validity across supported regions.
771    /// National values retain their original form rather than guessing a country code.
772    #[cfg(feature = "phone-parser")]
773    PhoneNumber,
774    /// Luhn checksum validator.
775    Luhn,
776    /// IBAN MOD-97 validator.
777    IbanMod97,
778    /// Strict decimal dotted-quad IPv4 parser.
779    Ipv4Parse,
780    /// RFC 4291 / RFC 5952 IPv6 textual parser.
781    Ipv6Parse,
782    /// IPv4 parser that excludes RFC 5737 documentation ranges.
783    Ipv4ParseNonDocumentation,
784    /// IPv6 parser that excludes RFC 3849 and embedded RFC 5737 ranges.
785    Ipv6ParseNonDocumentation,
786    /// EIP-55 Ethereum address checksum validator.
787    EthEip55,
788    /// Indian Aadhaar Verhoeff checksum validator.
789    AadhaarVerhoeff,
790    /// French NIR MOD-97 key validator.
791    FrNirMod97,
792    /// German Steuer-ID MOD 11,10 checksum validator.
793    DeSteuerIdMod1110,
794    /// Dutch BSN MOD-11 checksum validator.
795    BsnMod11,
796    /// Brazilian CPF MOD-11 checksum validator.
797    CpfMod11,
798    /// Brazilian CNPJ MOD-11 checksum validator.
799    CnpjMod11,
800    /// UK NHS number MOD-11 checksum validator.
801    UkNhsMod11,
802}
803
804/// Regions supported by national phone validators.
805#[cfg(feature = "phone-parser")]
806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807#[non_exhaustive]
808pub enum Region {
809    /// Germany.
810    De,
811    /// United States.
812    Us,
813    /// Austria.
814    At,
815    /// Switzerland.
816    Ch,
817    /// United Kingdom.
818    Gb,
819    /// Ireland.
820    Ie,
821    /// Australia.
822    Au,
823    /// Canada.
824    Ca,
825    /// New Zealand.
826    Nz,
827    /// South Africa.
828    Za,
829    /// France.
830    Fr,
831}
832
833/// What validator veto does with a candidate whose validator fails.
834#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
835#[non_exhaustive]
836pub enum ValidatorOnFail {
837    /// Drop the candidate and log it as a loser decided by `ConflictTier::ValidatorVeto`.
838    #[default]
839    Veto,
840    /// Keep the candidate, write the failure on its audit row and never let the repeat-value
841    /// sweep spread its value. Only [`ValidatorKind::allows_recorded_failure`] kinds take it.
842    Record,
843}
844
845impl ValidatorOnFail {
846    /// Parses a rulepack `on_fail` value (`veto` or `record`).
847    pub fn parse(s: &str) -> Option<Self> {
848        match s {
849            "veto" => Some(Self::Veto),
850            "record" => Some(Self::Record),
851            _ => None,
852        }
853    }
854}
855
856impl ValidatorKind {
857    /// Whether this validator checks phone values, including regional national forms.
858    pub fn is_phone(self) -> bool {
859        #[cfg(feature = "phone-parser")]
860        {
861            matches!(
862                self,
863                Self::E164Phone | Self::E164PhoneNational(_) | Self::PhoneNumber
864            )
865        }
866        #[cfg(not(feature = "phone-parser"))]
867        {
868            false
869        }
870    }
871
872    /// Whether a failed validation may keep a candidate with its failure reason.
873    /// Rulepacks opt in per recognizer; broad scanners must still veto.
874    pub fn allows_recorded_failure(self) -> bool {
875        matches!(
876            self,
877            Self::Luhn
878                | Self::IbanMod97
879                | Self::DeSteuerIdMod1110
880                | Self::BsnMod11
881                | Self::CpfMod11
882                | Self::UkNhsMod11
883        ) || {
884            #[cfg(feature = "phone-parser")]
885            {
886                matches!(
887                    self,
888                    Self::E164Phone | Self::PhoneNumber | Self::E164PhoneNational(Region::Us)
889                )
890            }
891            #[cfg(not(feature = "phone-parser"))]
892            {
893                false
894            }
895        }
896    }
897
898    /// Parses a policy validator kind.
899    pub fn parse(s: &str) -> Result<Self, ValidatorKindParseError> {
900        match s {
901            "email_rfc" => Ok(Self::EmailRfc),
902            #[cfg(feature = "phone-parser")]
903            "e164_phone" => Ok(Self::E164Phone),
904            #[cfg(feature = "phone-parser")]
905            "phone_number" => Ok(Self::PhoneNumber),
906            #[cfg(feature = "phone-parser")]
907            "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
908            #[cfg(feature = "phone-parser")]
909            "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
910            "luhn" => Ok(Self::Luhn),
911            "iban_mod97" => Ok(Self::IbanMod97),
912            "ipv4_parse" => Ok(Self::Ipv4Parse),
913            "ipv6_parse" => Ok(Self::Ipv6Parse),
914            "ipv4_parse_non_documentation" => Ok(Self::Ipv4ParseNonDocumentation),
915            "ipv6_parse_non_documentation" => Ok(Self::Ipv6ParseNonDocumentation),
916            "eth_eip55" => Ok(Self::EthEip55),
917            "aadhaar_verhoeff" => Ok(Self::AadhaarVerhoeff),
918            "fr_nir_mod97" => Ok(Self::FrNirMod97),
919            "de_steuer_id_mod1110" => Ok(Self::DeSteuerIdMod1110),
920            "bsn_mod11" => Ok(Self::BsnMod11),
921            "cpf_mod11" => Ok(Self::CpfMod11),
922            "cnpj_mod11" => Ok(Self::CnpjMod11),
923            "uk_nhs_mod11" => Ok(Self::UkNhsMod11),
924            other => Err(ValidatorKindParseError::UnsupportedValidator {
925                kind: other.to_string(),
926            }),
927        }
928    }
929
930    /// Returns whether the validator accepts the input.
931    pub fn validates(self, input: &str) -> bool {
932        match self {
933            Self::AadhaarVerhoeff => aadhaar_verhoeff_check(input),
934            Self::FrNirMod97 => fr_nir_mod97_check(input),
935            Self::DeSteuerIdMod1110 => de_steuer_id_mod1110_check(input),
936            Self::BsnMod11 => bsn_mod11_check(input),
937            Self::CpfMod11 => cpf_mod11_check(input),
938            Self::CnpjMod11 => cnpj_mod11_check(input),
939            Self::UkNhsMod11 => uk_nhs_mod11_check(input),
940            _ => self.canonical_form(input).is_some(),
941        }
942    }
943
944    /// Applies validation and returns a typed outcome for audit.
945    pub fn validate(self, input: &str) -> ValidatorOutcome {
946        match self.canonical_form(input) {
947            Some(canonical_form) => ValidatorOutcome::Pass {
948                canonical_form: Some(canonical_form),
949            },
950            None => ValidatorOutcome::Fail {
951                reason: match self {
952                    #[cfg(feature = "phone-parser")]
953                    Self::PhoneNumber if input.starts_with('+') || input.starts_with("00") => {
954                        ValidatorFailReason::PhoneE164Rejected
955                    }
956                    Self::Ipv4ParseNonDocumentation => match input.parse() {
957                        Ok(address) if ipv4_is_documentation(address) => {
958                            ValidatorFailReason::Ipv4DocumentationRange
959                        }
960                        Ok(_) => ValidatorFailReason::Ipv4LoopbackRange,
961                        Err(_) => self.fail_reason(),
962                    },
963                    Self::Ipv6ParseNonDocumentation => match input.parse() {
964                        Ok(address) if ipv6_is_documentation(address) => {
965                            ValidatorFailReason::Ipv6DocumentationRange
966                        }
967                        Ok(_) => ValidatorFailReason::Ipv6LoopbackRange,
968                        Err(_) => self.fail_reason(),
969                    },
970                    _ => self.fail_reason(),
971                },
972            },
973        }
974    }
975
976    /// Returns the canonical form for accepted input.
977    pub fn canonical_form(self, input: &str) -> Option<String> {
978        match self {
979            Self::EmailRfc => is_basic_email(input).then(|| input.to_string()),
980            #[cfg(feature = "phone-parser")]
981            Self::E164Phone => e164_phone_check(input).then(|| input.to_string()),
982            #[cfg(feature = "phone-parser")]
983            Self::E164PhoneNational(region) => validate_phone_national(region, input),
984            #[cfg(feature = "phone-parser")]
985            Self::PhoneNumber => phone_number_check(input).then(|| input.to_string()),
986            Self::Luhn => luhn_check(input).then(|| input.to_string()),
987            Self::IbanMod97 => iban_mod97_check(input).then(|| input.to_string()),
988            Self::Ipv4Parse => ipv4_parse_check(input).then(|| input.to_string()),
989            Self::Ipv6Parse => ipv6_parse_check(input).then(|| input.to_string()),
990            Self::Ipv4ParseNonDocumentation => input
991                .parse::<std::net::Ipv4Addr>()
992                .ok()
993                .filter(|address| !ipv4_is_documentation(*address) && !address.is_loopback())
994                .map(|_| input.to_string()),
995            Self::Ipv6ParseNonDocumentation => input
996                .parse::<std::net::Ipv6Addr>()
997                .ok()
998                .filter(|address| !ipv6_is_documentation(*address) && !ipv6_is_loopback(*address))
999                .map(|_| input.to_string()),
1000            Self::EthEip55 => eth_eip55_check(input).then(|| input.to_string()),
1001            Self::AadhaarVerhoeff => {
1002                canonical_ascii_digits::<12>(input).filter(|_| aadhaar_verhoeff_check(input))
1003            }
1004            Self::FrNirMod97 => {
1005                canonical_ascii_digits::<15>(input).filter(|_| fr_nir_mod97_check(input))
1006            }
1007            Self::DeSteuerIdMod1110 => {
1008                canonical_ascii_digits::<11>(input).filter(|_| de_steuer_id_mod1110_check(input))
1009            }
1010            Self::BsnMod11 => canonical_ascii_digits::<9>(input).filter(|_| bsn_mod11_check(input)),
1011            Self::CpfMod11 => {
1012                canonical_ascii_digits::<11>(input).filter(|_| cpf_mod11_check(input))
1013            }
1014            Self::CnpjMod11 => {
1015                canonical_ascii_digits::<14>(input).filter(|_| cnpj_mod11_check(input))
1016            }
1017            Self::UkNhsMod11 => {
1018                canonical_ascii_digits::<10>(input).filter(|_| uk_nhs_mod11_check(input))
1019            }
1020        }
1021    }
1022
1023    /// Returns the ordinary parse or checksum failure reason. Input-dependent
1024    /// exclusions report their own reason through [`Self::validate`].
1025    pub fn fail_reason(self) -> ValidatorFailReason {
1026        match self {
1027            Self::EmailRfc => ValidatorFailReason::EmailRfcRejected,
1028            #[cfg(feature = "phone-parser")]
1029            Self::E164Phone => ValidatorFailReason::PhoneE164Rejected,
1030            #[cfg(feature = "phone-parser")]
1031            Self::PhoneNumber => ValidatorFailReason::PhoneNationalRegionMismatch,
1032            #[cfg(feature = "phone-parser")]
1033            Self::E164PhoneNational(_) => ValidatorFailReason::PhoneNationalRegionMismatch,
1034            Self::Luhn => ValidatorFailReason::LuhnFailed,
1035            Self::IbanMod97 => ValidatorFailReason::IbanMod97Failed,
1036            Self::Ipv4Parse => ValidatorFailReason::Ipv4ParseFailed,
1037            Self::Ipv6Parse => ValidatorFailReason::Ipv6ParseFailed,
1038            Self::Ipv4ParseNonDocumentation => ValidatorFailReason::Ipv4ParseFailed,
1039            Self::Ipv6ParseNonDocumentation => ValidatorFailReason::Ipv6ParseFailed,
1040            Self::EthEip55 => ValidatorFailReason::EthEip55ChecksumFailed,
1041            Self::AadhaarVerhoeff => ValidatorFailReason::AadhaarVerhoeffFailed,
1042            Self::FrNirMod97 => ValidatorFailReason::FrNirMod97Failed,
1043            Self::DeSteuerIdMod1110 => ValidatorFailReason::DeSteuerIdMod1110Failed,
1044            Self::BsnMod11 => ValidatorFailReason::BsnMod11Failed,
1045            Self::CpfMod11 => ValidatorFailReason::CpfMod11Failed,
1046            Self::CnpjMod11 => ValidatorFailReason::CnpjMod11Failed,
1047            Self::UkNhsMod11 => ValidatorFailReason::UkNhsMod11Failed,
1048        }
1049    }
1050}
1051
1052fn is_basic_email(input: &str) -> bool {
1053    let Some((local, domain)) = input.split_once('@') else {
1054        return false;
1055    };
1056    !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1057}
1058
1059#[cfg(feature = "phone-parser")]
1060fn e164_phone_check(input: &str) -> bool {
1061    phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
1062}
1063
1064/// International dialing prefixes and optional trunk markers are display syntax.
1065#[cfg(feature = "phone-parser")]
1066fn phone_number_check(input: &str) -> bool {
1067    let normalized = input.strip_prefix("00").map(|tail| format!("+{tail}"));
1068    let value = normalized.as_deref().unwrap_or(input);
1069    if value.starts_with('+') {
1070        return phonenumber::parse(None, value).is_ok_and(|phone| {
1071            // Some national plans accept longer subscriber numbers. They cannot be
1072            // trusted as international values beyond E.164's 15-digit limit.
1073            // ITU-T E.164 Annex A.3.1.1: https://www.itu.int/rec/T-REC-E.164-202602-I/en
1074            if phone
1075                .format()
1076                .mode(phonenumber::Mode::E164)
1077                .to_string()
1078                .bytes()
1079                .filter(u8::is_ascii_digit)
1080                .count()
1081                > 15
1082            {
1083                return false;
1084            }
1085            phone.is_valid()
1086                || (phone.country().code() == 44
1087                    // The reserved-block exception must not bless a per-digit slash run
1088                    // that the parser rejected; recorded failures cannot seed repeats.
1089                    && value.bytes().filter(|byte| *byte == b'/').count() <= 1
1090                    && is_safe_fixture_phone(
1091                        Region::Gb,
1092                        &phone.format().mode(phonenumber::Mode::E164).to_string(),
1093                    ))
1094        });
1095    }
1096    [
1097        Region::De,
1098        Region::Us,
1099        Region::At,
1100        Region::Ch,
1101        Region::Gb,
1102        Region::Ie,
1103        Region::Au,
1104        Region::Ca,
1105        Region::Nz,
1106        Region::Za,
1107        Region::Fr,
1108    ]
1109    .into_iter()
1110    .any(|region| validate_phone_national(region, value).is_some())
1111}
1112
1113#[cfg(feature = "phone-parser")]
1114fn validate_phone_national(region: Region, input: &str) -> Option<String> {
1115    let country = match region {
1116        Region::De => phonenumber::country::DE,
1117        Region::Us => phonenumber::country::US,
1118        Region::At => phonenumber::country::AT,
1119        Region::Ch => phonenumber::country::CH,
1120        Region::Gb => phonenumber::country::GB,
1121        Region::Ie => phonenumber::country::IE,
1122        Region::Au => phonenumber::country::AU,
1123        Region::Ca => phonenumber::country::CA,
1124        Region::Nz => phonenumber::country::NZ,
1125        Region::Za => phonenumber::country::ZA,
1126        Region::Fr => phonenumber::country::FR,
1127    };
1128    let expected_code = match region {
1129        Region::De => 49,
1130        Region::Us | Region::Ca => 1,
1131        Region::At => 43,
1132        Region::Ch => 41,
1133        Region::Gb => 44,
1134        Region::Ie => 353,
1135        Region::Au => 61,
1136        Region::Nz => 64,
1137        Region::Za => 27,
1138        Region::Fr => 33,
1139    };
1140    let number = phonenumber::parse(Some(country), input).ok()?;
1141    if number.country().code() != expected_code {
1142        return None;
1143    }
1144    if number.is_valid() || is_safe_fixture_phone(region, input) {
1145        return Some(number.format().mode(phonenumber::Mode::E164).to_string());
1146    }
1147    None
1148}
1149
1150#[cfg(feature = "phone-parser")]
1151fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
1152    let digits = input
1153        .chars()
1154        .filter(char::is_ascii_digit)
1155        .collect::<String>();
1156    match region {
1157        Region::Us => {
1158            digits == "15550100"
1159                || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
1160        }
1161        // Ofcom reserves the entire mobile drama block; libphonenumber deliberately
1162        // excludes this allocation, but it remains the phone shape adopters use in tests.
1163        // https://www.ofcom.org.uk/phones-and-broadband/phone-numbers/numbers-for-drama
1164        Region::Gb => {
1165            if input.bytes().filter(|byte| *byte == b'/').count() > 1 {
1166                return false;
1167            }
1168            let national = digits
1169                .strip_prefix("44")
1170                .or_else(|| digits.strip_prefix('0'))
1171                .unwrap_or(&digits);
1172            national.len() == 10 && national.starts_with("7700900")
1173        }
1174        Region::De => matches!(
1175            digits.as_str(),
1176            "493000000000"
1177                | "4915100000000"
1178                | "4915550112233"
1179                | "015550112233"
1180                | "491710000000"
1181                | "01710000000"
1182        ),
1183        _ => false,
1184    }
1185}
1186
1187/// The one Luhn check: the `luhn` validator and [`payment_card::scan_card_run`] both call it.
1188/// It checks the ASCII digits. Whitespace (any Unicode `White_Space`, as the card patterns'
1189/// `\s`), `-` and non-ASCII digits (which the card patterns' `\d` matches but no card number is
1190/// written in) are skipped; any other character fails the check, and so does an ASCII digit count
1191/// outside 13 to 19 or an all-zero number. Fullwidth digits reach it already folded to ASCII by
1192/// normalization.
1193pub(crate) fn luhn_check(input: &str) -> bool {
1194    let mut digits = Vec::new();
1195    for ch in input.chars() {
1196        if ch.is_whitespace() || ch == '-' || (ch.is_numeric() && !ch.is_ascii_digit()) {
1197            continue;
1198        }
1199        if !ch.is_ascii_digit() {
1200            return false;
1201        }
1202        digits.push(ch as u8 - b'0');
1203    }
1204    if !(13..=19).contains(&digits.len()) || digits.iter().all(|digit| *digit == 0) {
1205        return false;
1206    }
1207
1208    let sum: u32 = digits
1209        .iter()
1210        .rev()
1211        .enumerate()
1212        .map(|(index, digit)| {
1213            let mut value = u32::from(*digit);
1214            if index % 2 == 1 {
1215                value *= 2;
1216                if value > 9 {
1217                    value -= 9;
1218                }
1219            }
1220            value
1221        })
1222        .sum();
1223    sum.is_multiple_of(10)
1224}
1225
1226fn iban_mod97_check(input: &str) -> bool {
1227    let canonical = iban_canonicalize(input);
1228    let bytes = canonical.as_bytes();
1229    if bytes.len() < 4 {
1230        return false;
1231    }
1232    if !bytes[0].is_ascii_uppercase()
1233        || !bytes[1].is_ascii_uppercase()
1234        || !bytes[2].is_ascii_digit()
1235        || !bytes[3].is_ascii_digit()
1236    {
1237        return false;
1238    }
1239    let Some(expected_len) = iban_country_length(&bytes[..2]) else {
1240        return false;
1241    };
1242    if bytes.len() != expected_len {
1243        return false;
1244    }
1245    if !bytes.iter().all(u8::is_ascii_alphanumeric) {
1246        return false;
1247    }
1248
1249    let mut remainder = 0u32;
1250    for byte in bytes[4..].iter().chain(bytes[..4].iter()).copied() {
1251        match byte {
1252            b'0'..=b'9' => {
1253                remainder = (remainder * 10 + u32::from(byte - b'0')) % 97;
1254            }
1255            b'A'..=b'Z' => {
1256                let value = u32::from(byte - b'A') + 10;
1257                remainder = (remainder * 10 + value / 10) % 97;
1258                remainder = (remainder * 10 + value % 10) % 97;
1259            }
1260            _ => return false,
1261        }
1262    }
1263    remainder == 1
1264}
1265
1266/// Canonical IBAN length for an ISO 3166-1 alpha-2 country code, or `None` when
1267/// the country is not in the ISO 13616 IBAN Registry.
1268///
1269/// This is the one source of truth for IBAN length in the workspace. The
1270/// `iban_mod97` validator gates candidates on it, and the `iban.structural`
1271/// pattern in the bundled `core` rulepack carries one alternation branch per
1272/// registry length so a candidate span stops at the country's real IBAN length
1273/// instead of borrowing the following word. The two are held
1274/// in agreement by a drift test in `gaze-recognizers`, which is why this needs
1275/// to be reachable from outside the crate.
1276#[must_use]
1277pub fn iban_registry_length(country: &str) -> Option<usize> {
1278    iban_country_length(country.as_bytes())
1279}
1280
1281fn iban_country_length(country: &[u8]) -> Option<usize> {
1282    // ISO 13616 IBAN Registry country lengths. MOD-97 alone has a false-accept
1283    // rate near 1/97, so exact country length gates candidates before checksum.
1284    match country {
1285        b"AD" => Some(24),
1286        b"AE" => Some(23),
1287        b"AL" => Some(28),
1288        b"AT" => Some(20),
1289        b"AZ" => Some(28),
1290        b"BA" => Some(20),
1291        b"BE" => Some(16),
1292        b"BG" => Some(22),
1293        b"BH" => Some(22),
1294        b"BI" => Some(27),
1295        b"BR" => Some(29),
1296        b"BY" => Some(28),
1297        b"CH" => Some(21),
1298        b"CR" => Some(22),
1299        b"CY" => Some(28),
1300        b"CZ" => Some(24),
1301        b"DE" => Some(22),
1302        b"DJ" => Some(27),
1303        b"DK" => Some(18),
1304        b"DO" => Some(28),
1305        b"EE" => Some(20),
1306        b"EG" => Some(29),
1307        b"ES" => Some(24),
1308        b"FI" => Some(18),
1309        b"FK" => Some(18),
1310        b"FO" => Some(18),
1311        b"FR" => Some(27),
1312        b"GB" => Some(22),
1313        b"GE" => Some(22),
1314        b"GI" => Some(23),
1315        b"GL" => Some(18),
1316        b"GR" => Some(27),
1317        b"GT" => Some(28),
1318        b"HN" => Some(28),
1319        b"HR" => Some(21),
1320        b"HU" => Some(28),
1321        b"IE" => Some(22),
1322        b"IL" => Some(23),
1323        b"IQ" => Some(23),
1324        b"IS" => Some(26),
1325        b"IT" => Some(27),
1326        b"JO" => Some(30),
1327        b"KW" => Some(30),
1328        b"KZ" => Some(20),
1329        b"LB" => Some(28),
1330        b"LC" => Some(32),
1331        b"LI" => Some(21),
1332        b"LT" => Some(20),
1333        b"LU" => Some(20),
1334        b"LV" => Some(21),
1335        b"LY" => Some(25),
1336        b"MC" => Some(27),
1337        b"MD" => Some(24),
1338        b"ME" => Some(22),
1339        b"MK" => Some(19),
1340        b"MN" => Some(20),
1341        b"MR" => Some(27),
1342        b"MT" => Some(31),
1343        b"MU" => Some(30),
1344        b"NI" => Some(28),
1345        b"NL" => Some(18),
1346        b"NO" => Some(15),
1347        b"OM" => Some(23),
1348        b"PK" => Some(24),
1349        b"PL" => Some(28),
1350        b"PS" => Some(29),
1351        b"PT" => Some(25),
1352        b"QA" => Some(29),
1353        b"RO" => Some(24),
1354        b"RS" => Some(22),
1355        b"RU" => Some(33),
1356        b"SA" => Some(24),
1357        b"SC" => Some(31),
1358        b"SD" => Some(18),
1359        b"SE" => Some(24),
1360        b"SI" => Some(19),
1361        b"SK" => Some(24),
1362        b"SM" => Some(27),
1363        b"SO" => Some(23),
1364        b"ST" => Some(25),
1365        b"SV" => Some(28),
1366        b"TL" => Some(23),
1367        b"TN" => Some(24),
1368        b"TR" => Some(26),
1369        b"UA" => Some(29),
1370        b"VA" => Some(22),
1371        b"VG" => Some(24),
1372        b"XK" => Some(20),
1373        b"YE" => Some(30),
1374        _ => None,
1375    }
1376}
1377
1378fn iban_canonicalize(input: &str) -> String {
1379    input
1380        .chars()
1381        .filter(|ch| !ch.is_ascii_whitespace())
1382        .flat_map(char::to_uppercase)
1383        .collect()
1384}
1385
1386fn ipv4_parse_check(input: &str) -> bool {
1387    input.parse::<std::net::Ipv4Addr>().is_ok()
1388}
1389
1390fn ipv6_parse_check(input: &str) -> bool {
1391    input.parse::<std::net::Ipv6Addr>().is_ok()
1392}
1393
1394fn ipv4_is_documentation(address: std::net::Ipv4Addr) -> bool {
1395    let [a, b, c, _] = address.octets();
1396    matches!((a, b, c), (192, 0, 2) | (198, 51, 100) | (203, 0, 113))
1397}
1398
1399fn ipv6_is_documentation(address: std::net::Ipv6Addr) -> bool {
1400    let segments = address.segments();
1401    (segments[0] == 0x2001 && segments[1] == 0x0db8)
1402        || address.to_ipv4().is_some_and(ipv4_is_documentation)
1403}
1404
1405/// `::1`, or an IPv4-mapped (`::ffff:127.0.0.1`) or IPv4-compatible (`::127.0.0.1`) loopback. A loopback
1406/// address never leaves the host, so it identifies no person or device.
1407fn ipv6_is_loopback(address: std::net::Ipv6Addr) -> bool {
1408    address.is_loopback() || address.to_ipv4().is_some_and(|v4| v4.is_loopback())
1409}
1410
1411fn eth_eip55_check(input: &str) -> bool {
1412    let Some(address) = input.strip_prefix("0x") else {
1413        return false;
1414    };
1415    if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1416        return false;
1417    }
1418    if address
1419        .bytes()
1420        .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
1421        || address
1422            .bytes()
1423            .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
1424    {
1425        return true;
1426    }
1427
1428    let lowercase = address.to_ascii_lowercase();
1429    let hash = Keccak256::digest(lowercase.as_bytes());
1430    for (index, byte) in address.bytes().enumerate() {
1431        if byte.is_ascii_digit() {
1432            continue;
1433        }
1434        let hash_nibble = if index % 2 == 0 {
1435            hash[index / 2] >> 4
1436        } else {
1437            hash[index / 2] & 0x0f
1438        };
1439        if (hash_nibble > 7) != byte.is_ascii_uppercase() {
1440            return false;
1441        }
1442    }
1443    true
1444}
1445
1446fn collect_ascii_digits<const N: usize>(input: &str) -> Option<[u8; N]> {
1447    let mut digits = [0u8; N];
1448    let mut count = 0usize;
1449    for byte in input.bytes() {
1450        if byte.is_ascii_digit() {
1451            if count == N {
1452                return None;
1453            }
1454            digits[count] = byte - b'0';
1455            count += 1;
1456        } else if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' | b'/') {
1457            continue;
1458        } else {
1459            return None;
1460        }
1461    }
1462    (count == N).then_some(digits)
1463}
1464
1465fn canonical_ascii_digits<const N: usize>(input: &str) -> Option<String> {
1466    let digits = collect_ascii_digits::<N>(input)?;
1467    let mut canonical = String::with_capacity(N);
1468    for digit in digits {
1469        canonical.push(char::from(b'0' + digit));
1470    }
1471    Some(canonical)
1472}
1473
1474fn not_all_same<const N: usize>(digits: &[u8; N]) -> bool {
1475    digits[1..].iter().any(|digit| *digit != digits[0])
1476}
1477
1478fn aadhaar_verhoeff_check(input: &str) -> bool {
1479    const D: [[u8; 10]; 10] = [
1480        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1481        [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
1482        [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
1483        [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
1484        [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
1485        [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
1486        [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
1487        [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
1488        [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
1489        [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
1490    ];
1491    const P: [[u8; 10]; 8] = [
1492        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1493        [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1494        [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1495        [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1496        [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1497        [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1498        [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1499        [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1500    ];
1501    let Some(digits) = collect_ascii_digits::<12>(input) else {
1502        return false;
1503    };
1504    if digits[0] < 2 || !not_all_same(&digits) {
1505        return false;
1506    }
1507    let mut checksum = 0u8;
1508    for (index, digit) in digits.iter().rev().enumerate() {
1509        checksum = D[checksum as usize][P[index % 8][*digit as usize] as usize];
1510    }
1511    checksum == 0
1512}
1513
1514fn fr_nir_mod97_check(input: &str) -> bool {
1515    let Some(digits) = collect_ascii_digits::<15>(input) else {
1516        return false;
1517    };
1518    if !matches!(digits[0], 1 | 2 | 3 | 4 | 7 | 8) {
1519        return false;
1520    }
1521    let month = digits[3] * 10 + digits[4];
1522    if !(1..=12).contains(&month) && !(20..=42).contains(&month) && !(50..=99).contains(&month) {
1523        return false;
1524    }
1525    let mut number = 0u32;
1526    for digit in &digits[..13] {
1527        number = (number * 10 + u32::from(*digit)) % 97;
1528    }
1529    let key = u32::from(digits[13]) * 10 + u32::from(digits[14]);
1530    97 - number == key
1531}
1532
1533fn de_steuer_id_mod1110_check(input: &str) -> bool {
1534    let Some(digits) = collect_ascii_digits::<11>(input) else {
1535        return false;
1536    };
1537    if !steuer_id_first_ten_digits_valid(&digits) {
1538        return false;
1539    }
1540    let mut product = 10u8;
1541    for digit in &digits[..10] {
1542        let mut sum = (*digit + product) % 10;
1543        if sum == 0 {
1544            sum = 10;
1545        }
1546        product = (2 * sum) % 11;
1547    }
1548    let check = (11 - product) % 10;
1549    check == digits[10]
1550}
1551
1552fn steuer_id_first_ten_digits_valid(digits: &[u8; 11]) -> bool {
1553    if digits[0] == 0 {
1554        return false;
1555    }
1556    let mut counts = [0u8; 10];
1557    for digit in &digits[..10] {
1558        counts[*digit as usize] += 1;
1559    }
1560    let repeated_digits = counts.iter().filter(|count| **count > 1).count();
1561    let missing_digits = counts.iter().filter(|count| **count == 0).count();
1562    let repeated_count_valid = counts.iter().any(|count| matches!(*count, 2 | 3));
1563    repeated_digits == 1 && repeated_count_valid && matches!(missing_digits, 1 | 2)
1564}
1565
1566fn bsn_mod11_check(input: &str) -> bool {
1567    let Some(digits) = collect_ascii_digits::<9>(input) else {
1568        return false;
1569    };
1570    if !not_all_same(&digits) {
1571        return false;
1572    }
1573    let sum: i32 = digits[..8]
1574        .iter()
1575        .enumerate()
1576        .map(|(index, digit)| i32::from(*digit) * (9 - index as i32))
1577        .sum::<i32>()
1578        - i32::from(digits[8]);
1579    sum.rem_euclid(11) == 0
1580}
1581
1582fn cpf_mod11_check(input: &str) -> bool {
1583    let Some(digits) = collect_ascii_digits::<11>(input) else {
1584        return false;
1585    };
1586    if !not_all_same(&digits) {
1587        return false;
1588    }
1589    mod11_check_digit(&digits[..9], 10) == digits[9]
1590        && mod11_check_digit(&digits[..10], 11) == digits[10]
1591}
1592
1593fn cnpj_mod11_check(input: &str) -> bool {
1594    let Some(digits) = collect_ascii_digits::<14>(input) else {
1595        return false;
1596    };
1597    if !not_all_same(&digits) {
1598        return false;
1599    }
1600    const FIRST: [u8; 12] = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1601    const SECOND: [u8; 13] = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1602    weighted_mod11_check_digit(&digits[..12], &FIRST) == digits[12]
1603        && weighted_mod11_check_digit(&digits[..13], &SECOND) == digits[13]
1604}
1605
1606fn uk_nhs_mod11_check(input: &str) -> bool {
1607    let Some(digits) = collect_ascii_digits::<10>(input) else {
1608        return false;
1609    };
1610    if !not_all_same(&digits) {
1611        return false;
1612    }
1613    let sum: u32 = digits[..9]
1614        .iter()
1615        .enumerate()
1616        .map(|(index, digit)| u32::from(*digit) * (10 - index as u32))
1617        .sum();
1618    let check = 11 - (sum % 11);
1619    let check = if check == 11 { 0 } else { check };
1620    check != 10 && check == u32::from(digits[9])
1621}
1622
1623fn mod11_check_digit(digits: &[u8], start_weight: u8) -> u8 {
1624    let weights = (2..=start_weight).rev();
1625    let sum: u32 = digits
1626        .iter()
1627        .zip(weights)
1628        .map(|(digit, weight)| u32::from(*digit) * u32::from(weight))
1629        .sum();
1630    let remainder = sum % 11;
1631    if remainder < 2 {
1632        0
1633    } else {
1634        (11 - remainder) as u8
1635    }
1636}
1637
1638fn weighted_mod11_check_digit(digits: &[u8], weights: &[u8]) -> u8 {
1639    let sum: u32 = digits
1640        .iter()
1641        .zip(weights)
1642        .map(|(digit, weight)| u32::from(*digit) * u32::from(*weight))
1643        .sum();
1644    let remainder = sum % 11;
1645    if remainder < 2 {
1646        0
1647    } else {
1648        (11 - remainder) as u8
1649    }
1650}
1651
1652/// A detected span and its class/source metadata.
1653#[derive(Debug, Clone, PartialEq, Eq)]
1654#[non_exhaustive]
1655pub struct Detection {
1656    /// Byte span in the original input.
1657    pub span: Range<usize>,
1658    /// PII class assigned to the span.
1659    pub class: PiiClass,
1660    /// Detector source identifier.
1661    pub source: String,
1662}
1663
1664impl Detection {
1665    /// Builds a detected PII span.
1666    pub fn new(span: Range<usize>, class: PiiClass, source: impl Into<String>) -> Self {
1667        Self {
1668            span,
1669            class,
1670            source: source.into(),
1671        }
1672    }
1673}
1674
1675/// Observer-only post-clean check (Pass 3 in the detection pipeline).
1676///
1677/// Runs against already-tokenized output. May report suspected missed PII via
1678/// [`LeakReport`] but **must not** mutate the token manifest, the `CleanDocument`,
1679/// or the restore path. Safety nets are additive defense-in-depth, not a replacement
1680/// for Pass 1/2 detection.
1681///
1682/// Activate at runtime with `Pipeline::with_safety_net` (post-build) or
1683/// `PipelineBuilder::register_safety_net` (during build), or via the CLI
1684/// `--safety-net=<name>` flag.
1685///
1686/// If a safety net reports a suspected miss, the caller decides the response; the
1687/// pipeline never silently re-cleans based on safety net output.
1688pub trait SafetyNet: Send + Sync {
1689    /// Stable backend identifier used in telemetry and audit rows.
1690    fn id(&self) -> &str;
1691
1692    /// Locale tags supported by this safety net. Empty means global.
1693    fn supported_locales(&self) -> &[LocaleTag];
1694
1695    /// Checks clean text for possible PII that the manifest did not cover.
1696    fn check(
1697        &self,
1698        clean_text: &str,
1699        context: SafetyNetContext<'_>,
1700    ) -> Result<Vec<LeakSuspect>, SafetyNetError>;
1701
1702    /// Checks a second, byte-aligned view when the backend supports it.
1703    /// Other safety nets keep their existing single-view behavior.
1704    fn check_with_neutral(
1705        &self,
1706        stable_text: &str,
1707        _neutral_text: Option<&str>,
1708        context: SafetyNetContext<'_>,
1709    ) -> Result<Vec<LeakSuspect>, SafetyNetError> {
1710        self.check(stable_text, context)
1711    }
1712
1713    /// Checks byte-aligned views and returns bytes-free refusal telemetry.
1714    /// Backends that use a neutral view override this to retain both views.
1715    fn check_with_neutral_and_telemetry(
1716        &self,
1717        stable_text: &str,
1718        _neutral_text: Option<&str>,
1719        context: SafetyNetContext<'_>,
1720    ) -> Result<(Vec<LeakSuspect>, Vec<LeakReportTelemetry>), SafetyNetError> {
1721        self.check_with_telemetry(stable_text, context)
1722    }
1723
1724    /// Checks text and returns metadata for spans deliberately refused by the backend.
1725    /// Existing implementations keep their suspect-only behavior.
1726    fn check_with_telemetry(
1727        &self,
1728        clean_text: &str,
1729        context: SafetyNetContext<'_>,
1730    ) -> Result<(Vec<LeakSuspect>, Vec<LeakReportTelemetry>), SafetyNetError> {
1731        self.check(clean_text, context)
1732            .map(|suspects| (suspects, Vec::new()))
1733    }
1734}
1735
1736/// Context passed to a privacy safety net.
1737#[derive(Debug, Clone, Copy)]
1738#[non_exhaustive]
1739pub struct SafetyNetContext<'a> {
1740    /// Optional caller-supplied dictionaries. `new` leaves this unset; pipeline
1741    /// wrappers supply the caller bundle or `Some(empty default)`. Locale-aware
1742    /// model backends have their separate text/locale interface without dictionaries.
1743    pub dictionaries: Option<&'a DictionaryBundle>,
1744    /// Tokens emitted by the pipeline or verified as owned at a strict boundary.
1745    /// At that boundary, raw spans use expanded-owner input coordinates, not
1746    /// offsets into the literal token-bearing input. Do not index that literal
1747    /// input with reconstructed raw spans.
1748    pub manifest: &'a Manifest,
1749    /// Active session-level locale chain. For `RawDocument::Structured`, locale
1750    /// gating uses this same session-level chain across all fields; structured
1751    /// fields do not carry per-field locale annotations.
1752    pub locale_chain: &'a [LocaleTag],
1753    /// Source document kind being checked.
1754    pub document_kind: DocumentKind,
1755    /// Optional audit session identifier.
1756    pub session_id: Option<&'a str>,
1757    /// Structured-document field path, such as `$.user.email`.
1758    pub field_path: Option<&'a str>,
1759}
1760
1761impl<'a> SafetyNetContext<'a> {
1762    /// Supplies the same dictionary bundle used by primary detection.
1763    pub fn with_dictionaries(mut self, dictionaries: &'a DictionaryBundle) -> Self {
1764        self.dictionaries = Some(dictionaries);
1765        self
1766    }
1767
1768    /// Builds safety-net context for one clean text segment.
1769    pub fn new(
1770        manifest: &'a Manifest,
1771        locale_chain: &'a [LocaleTag],
1772        document_kind: DocumentKind,
1773        session_id: Option<&'a str>,
1774        field_path: Option<&'a str>,
1775    ) -> Self {
1776        Self {
1777            dictionaries: None,
1778            manifest,
1779            locale_chain,
1780            document_kind,
1781            session_id,
1782            field_path,
1783        }
1784    }
1785}
1786
1787/// What a replacement covers: a whole recognized selection, or a residual
1788/// fragment of the admitted raw union that whole-candidate arbitration dropped.
1789///
1790/// This is the distinction a consumer needs before it may treat a replacement as
1791/// an entity. A fragment is a real protected byte range, but it is *not* an
1792/// entity: it can be a single space, quote or letter carved out of the middle of
1793/// one. Indexing or counting it as an entity produces a falsehood.
1794///
1795/// Serialization is backward compatible in both directions. `Whole` is the
1796/// default and is omitted from output, so JSON written for existing whole-value
1797/// spans stays byte-identical, and JSON written before this field existed
1798/// deserializes as `Whole`.
1799#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
1800#[serde(rename_all = "snake_case")]
1801#[non_exhaustive]
1802pub enum EmittedTokenOrigin {
1803    /// Covers a whole selection chosen by conflict resolution.
1804    #[default]
1805    Whole,
1806    /// Covers raw bytes that admitted originals evidenced but no selection kept.
1807    ResidualFragment,
1808}
1809
1810impl EmittedTokenOrigin {
1811    /// Whether this replacement covers a whole selection.
1812    pub fn is_whole(&self) -> bool {
1813        matches!(self, Self::Whole)
1814    }
1815
1816    /// Whether this replacement covers a residual fragment.
1817    pub fn is_residual_fragment(&self) -> bool {
1818        matches!(self, Self::ResidualFragment)
1819    }
1820}
1821
1822/// A replacement emitted by the pseudonymization pipeline.
1823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1824#[non_exhaustive]
1825pub struct EmittedTokenSpan {
1826    /// Byte span in the clean text.
1827    pub clean_span: Range<usize>,
1828    /// Byte span in the raw text that produced the token.
1829    pub raw_span: Range<usize>,
1830    /// PII class represented by the emitted token.
1831    pub class: PiiClass,
1832    /// Whether this replacement covers a whole selection or a residual fragment.
1833    #[serde(default, skip_serializing_if = "EmittedTokenOrigin::is_whole")]
1834    pub origin: EmittedTokenOrigin,
1835}
1836
1837impl EmittedTokenSpan {
1838    /// Builds an emitted token span covering a whole selection.
1839    pub fn new(clean_span: Range<usize>, raw_span: Range<usize>, class: PiiClass) -> Self {
1840        Self {
1841            clean_span,
1842            raw_span,
1843            class,
1844            origin: EmittedTokenOrigin::Whole,
1845        }
1846    }
1847
1848    /// Builds an emitted token span covering a residual fragment.
1849    pub fn residual_fragment(
1850        clean_span: Range<usize>,
1851        raw_span: Range<usize>,
1852        class: PiiClass,
1853    ) -> Self {
1854        Self {
1855            clean_span,
1856            raw_span,
1857            class,
1858            origin: EmittedTokenOrigin::ResidualFragment,
1859        }
1860    }
1861}
1862
1863/// Set of emitted token spans for one clean text segment.
1864#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1865#[non_exhaustive]
1866pub struct Manifest {
1867    /// Spans sorted by `clean_span.start`.
1868    pub spans: Vec<EmittedTokenSpan>,
1869}
1870
1871impl Manifest {
1872    /// Builds a manifest from spans and sorts them by clean byte start.
1873    pub fn from_spans(mut spans: Vec<EmittedTokenSpan>) -> Self {
1874        spans.sort_by_key(|span| (span.clean_span.start, span.clean_span.end));
1875        Self { spans }
1876    }
1877
1878    /// Diffs one safety-net suspect span against emitted token coverage.
1879    ///
1880    /// Returns `None` when the suspect span is continuously covered by emitted
1881    /// token spans of the same class. Internal gaps return
1882    /// `LeakKind::PartialBleed`. When multiple uncovered gaps exist, this method
1883    /// deterministically returns the first gap by byte offset; full gap
1884    /// enumeration is intentionally deferred to a future report format.
1885    pub fn diff_against(
1886        &self,
1887        suspect_span: &Range<usize>,
1888        suspect_class: &PiiClass,
1889    ) -> Option<LeakKind> {
1890        if suspect_span.is_empty() {
1891            return None;
1892        }
1893
1894        let start_idx = self
1895            .spans
1896            .partition_point(|span| span.clean_span.end <= suspect_span.start);
1897        let overlapping = self.spans[start_idx..]
1898            .iter()
1899            .take_while(|span| span.clean_span.start < suspect_span.end)
1900            .filter(|span| ranges_overlap(&span.clean_span, suspect_span))
1901            .collect::<Vec<_>>();
1902
1903        if overlapping.is_empty() {
1904            return Some(LeakKind::Uncovered);
1905        }
1906
1907        let mut cursor = suspect_span.start;
1908        let mut first_mismatch = None::<&EmittedTokenSpan>;
1909        for span in overlapping {
1910            if span.clean_span.start > cursor {
1911                return Some(LeakKind::PartialBleed {
1912                    uncovered: cursor..span.clean_span.start.min(suspect_span.end),
1913                });
1914            }
1915
1916            if span.clean_span.end > cursor {
1917                if first_mismatch.is_none() && &span.class != suspect_class {
1918                    first_mismatch = Some(span);
1919                }
1920                cursor = cursor.max(span.clean_span.end.min(suspect_span.end));
1921                if cursor >= suspect_span.end {
1922                    break;
1923                }
1924            }
1925        }
1926
1927        if cursor < suspect_span.end {
1928            return Some(LeakKind::PartialBleed {
1929                uncovered: cursor..suspect_span.end,
1930            });
1931        }
1932
1933        first_mismatch.map(|span| LeakKind::ClassMismatch {
1934            pipeline_class: span.class.clone(),
1935            safety_net_class: suspect_class.clone(),
1936        })
1937    }
1938}
1939
1940fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1941    left.start < right.end && right.start < left.end
1942}
1943
1944/// Suspected leak reported by an observer-only safety net.
1945#[derive(Debug, Clone, PartialEq)]
1946#[non_exhaustive]
1947pub struct LeakSuspect {
1948    /// Byte span in clean text.
1949    pub span: Range<usize>,
1950    /// Mapped PII class for the suspect.
1951    pub class: PiiClass,
1952    /// Safety-net backend identifier.
1953    pub safety_net_id: String,
1954    /// Optional backend confidence score.
1955    pub score: Option<f32>,
1956    /// Leak classification after manifest correlation.
1957    pub kind: LeakKind,
1958    /// Raw backend label after validation/mapping, never source text.
1959    pub raw_label: String,
1960    /// Optional structured field path.
1961    pub field_path: Option<String>,
1962}
1963
1964impl LeakSuspect {
1965    /// Builds a safety-net leak suspect.
1966    pub fn new(
1967        span: Range<usize>,
1968        class: PiiClass,
1969        safety_net_id: impl Into<String>,
1970        score: Option<f32>,
1971        kind: LeakKind,
1972        raw_label: impl Into<String>,
1973        field_path: Option<String>,
1974    ) -> Self {
1975        Self {
1976            span,
1977            class,
1978            safety_net_id: safety_net_id.into(),
1979            score,
1980            kind,
1981            raw_label: raw_label.into(),
1982            field_path,
1983        }
1984    }
1985}
1986
1987/// The category of a suspected missed PII span.
1988///
1989/// `LeakKind` is `#[non_exhaustive]`. Match with a wildcard for forward compatibility.
1990#[derive(Debug, Clone, PartialEq, Eq)]
1991#[non_exhaustive]
1992pub enum LeakKind {
1993    /// No same-class emitted token overlaps the suspect span.
1994    Uncovered,
1995    /// The suspect is only partly covered; `uncovered` is the first gap.
1996    PartialBleed {
1997        /// First uncovered byte range in the suspect span.
1998        uncovered: Range<usize>,
1999    },
2000    /// The suspect is continuously covered, but by a different class.
2001    ClassMismatch {
2002        /// Class emitted by the pipeline.
2003        pipeline_class: PiiClass,
2004        /// Class reported by the safety net.
2005        safety_net_class: PiiClass,
2006    },
2007}
2008
2009impl LeakKind {
2010    /// Returns the canonical audit-row spelling.
2011    pub fn as_str(&self) -> &'static str {
2012        match self {
2013            Self::Uncovered => "uncovered",
2014            Self::PartialBleed { .. } => "partial_bleed",
2015            Self::ClassMismatch { .. } => "class_mismatch",
2016        }
2017    }
2018}
2019
2020/// Bytes-free telemetry emitted by safety-net orchestration.
2021#[derive(Debug, Clone, PartialEq, Eq)]
2022#[non_exhaustive]
2023pub enum LeakReportTelemetry {
2024    /// Safety net skipped because the session-level locale chain did not match.
2025    LocaleSkipped {
2026        /// Safety-net backend identifier.
2027        safety_net_id: String,
2028        /// Document kind checked.
2029        document_kind: DocumentKind,
2030        /// Optional structured field path when skip was recorded per field.
2031        field_path: Option<String>,
2032    },
2033    /// A word-like suspect (name, location, organization) whose action span starts or ends
2034    /// inside a word in the text it was reported on.
2035    ///
2036    /// The suspect is not tokenized and not deleted: acting on part of a word mangles the text
2037    /// the agent reads and protects nothing whole. It stays in the report's suspects; this row
2038    /// says why no stage acted on it. Offsets are in the text of the pass that reported it, so a
2039    /// re-run after earlier tokens shifted the text can add a row for the same sub-word.
2040    UnactionableSubword {
2041        /// Safety-net backend identifier.
2042        safety_net_id: String,
2043        /// Class the net reported.
2044        class: PiiClass,
2045        /// Byte span of the would-be action in the text the net checked.
2046        span: Range<usize>,
2047        /// Document kind checked.
2048        document_kind: DocumentKind,
2049        /// Optional structured field path of the suspect.
2050        field_path: Option<String>,
2051    },
2052    /// A validated model span rejected by a narrow, versioned context guard.
2053    ModelSpanRefused {
2054        /// Safety-net backend identifier.
2055        safety_net_id: String,
2056        /// Typed refusal reason, without source text.
2057        reason: SafetyNetRefusalReason,
2058        /// Byte span in clean text.
2059        span: Range<usize>,
2060        /// Document kind checked.
2061        document_kind: DocumentKind,
2062        /// Optional structured field path.
2063        field_path: Option<String>,
2064    },
2065}
2066
2067/// Closed reasons for refusing a validated safety-net model span.
2068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2069pub enum SafetyNetRefusalReason {
2070    /// Nym BUILDING_NUMBER on an ASCII-digit pagination/count field from key table v1.
2071    NymPaginationKeyV1,
2072}
2073
2074impl SafetyNetRefusalReason {
2075    /// Stable metadata-only audit spelling.
2076    pub fn as_str(self) -> &'static str {
2077        match self {
2078            Self::NymPaginationKeyV1 => "nym_pagination_key_v1",
2079        }
2080    }
2081
2082    /// Raw model label and mapped class for a refused span's audit row.
2083    pub fn audit_labels(self) -> (&'static str, &'static str) {
2084        match self {
2085            Self::NymPaginationKeyV1 => ("BUILDING_NUMBER", "custom:building_number"),
2086        }
2087    }
2088}
2089
2090/// Aggregate leak report statistics.
2091#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2092#[non_exhaustive]
2093pub struct LeakReportStats {
2094    /// Number of suspects reported.
2095    pub suspect_count: usize,
2096    /// Number of uncovered suspects.
2097    pub uncovered_count: usize,
2098    /// Number of partial-bleed suspects.
2099    pub partial_bleed_count: usize,
2100    /// Number of class-mismatch suspects.
2101    pub class_mismatch_count: usize,
2102    /// Number of locale-skip telemetry events.
2103    pub locale_skipped_count: usize,
2104}
2105
2106/// Signed document-context metadata carried inside a session snapshot envelope.
2107///
2108/// This extension is the v0.7 bridge for `gaze-document`: it is safe to serialize
2109/// inside the owner-only snapshot envelope, while agent-facing files keep using
2110/// non-sensitive mirrors. The single `schema_version` is bundle-level; sub-files
2111/// do not carry independent schema versions.
2112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2113#[non_exhaustive]
2114pub struct DocumentExtension {
2115    /// Bundle-level schema version shared by clean, layout, preview, report, and manifest files.
2116    pub schema_version: u16,
2117    /// SHA-256 of `clean.md` NFC-normalized bytes.
2118    pub clean_md_sha256: [u8; 32],
2119    /// SHA-256 of canonical `layout.json` bytes.
2120    pub layout_json_sha256: [u8; 32],
2121    /// SHA-256 of canonical `report.json` bytes.
2122    pub report_json_sha256: [u8; 32],
2123    /// SHA-256 of `preview-redacted.png` bytes when a preview is present.
2124    #[serde(default, skip_serializing_if = "Option::is_none")]
2125    pub preview_png_sha256: Option<[u8; 32]>,
2126    /// Page count reported for the source document.
2127    pub page_count: u32,
2128    /// Audit session id mirrored from the writing session for cross-pane correlation.
2129    pub audit_session_id: String,
2130    /// Signed clean.md byte spans for every emitted token.
2131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2132    pub clean_spans: Vec<EmittedTokenSpan>,
2133    /// Codec audit rows for the decode path that produced this document extension.
2134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2135    pub codec_audit: Vec<CodecAuditRow>,
2136}
2137
2138impl DocumentExtension {
2139    /// Starts a document extension builder for one bundle schema version.
2140    pub fn builder(schema_version: u16) -> DocumentExtensionBuilder {
2141        DocumentExtensionBuilder {
2142            schema_version,
2143            clean_md_sha256: None,
2144            layout_json_sha256: None,
2145            report_json_sha256: None,
2146            preview_png_sha256: None,
2147            page_count: None,
2148            audit_session_id: None,
2149            clean_spans: Vec::new(),
2150            codec_audit: Vec::new(),
2151        }
2152    }
2153}
2154
2155/// Builder for [`DocumentExtension`] that requires signed integrity-binding fields.
2156#[derive(Debug, Clone)]
2157#[must_use]
2158pub struct DocumentExtensionBuilder {
2159    schema_version: u16,
2160    clean_md_sha256: Option<[u8; 32]>,
2161    layout_json_sha256: Option<[u8; 32]>,
2162    report_json_sha256: Option<[u8; 32]>,
2163    preview_png_sha256: Option<[u8; 32]>,
2164    page_count: Option<u32>,
2165    audit_session_id: Option<String>,
2166    clean_spans: Vec<EmittedTokenSpan>,
2167    codec_audit: Vec<CodecAuditRow>,
2168}
2169
2170impl DocumentExtensionBuilder {
2171    pub fn clean_md_sha256(mut self, hash: [u8; 32]) -> Self {
2172        self.clean_md_sha256 = Some(hash);
2173        self
2174    }
2175
2176    pub fn layout_json_sha256(mut self, hash: [u8; 32]) -> Self {
2177        self.layout_json_sha256 = Some(hash);
2178        self
2179    }
2180
2181    pub fn report_json_sha256(mut self, hash: [u8; 32]) -> Self {
2182        self.report_json_sha256 = Some(hash);
2183        self
2184    }
2185
2186    pub fn preview_png_sha256(mut self, hash: [u8; 32]) -> Self {
2187        self.preview_png_sha256 = Some(hash);
2188        self
2189    }
2190
2191    pub fn page_count(mut self, page_count: u32) -> Self {
2192        self.page_count = Some(page_count);
2193        self
2194    }
2195
2196    pub fn audit_session_id(mut self, audit_session_id: impl Into<String>) -> Self {
2197        self.audit_session_id = Some(audit_session_id.into());
2198        self
2199    }
2200
2201    pub fn clean_spans(mut self, clean_spans: Vec<EmittedTokenSpan>) -> Self {
2202        self.clean_spans = clean_spans;
2203        self
2204    }
2205
2206    pub fn codec_audit(mut self, codec_audit: Vec<CodecAuditRow>) -> Self {
2207        self.codec_audit = codec_audit;
2208        self
2209    }
2210
2211    pub fn build(self) -> Result<DocumentExtension, DocumentExtensionError> {
2212        Ok(DocumentExtension {
2213            schema_version: self.schema_version,
2214            clean_md_sha256: self
2215                .clean_md_sha256
2216                .ok_or(DocumentExtensionError::MissingField("clean_md_sha256"))?,
2217            layout_json_sha256: self
2218                .layout_json_sha256
2219                .ok_or(DocumentExtensionError::MissingField("layout_json_sha256"))?,
2220            report_json_sha256: self
2221                .report_json_sha256
2222                .ok_or(DocumentExtensionError::MissingField("report_json_sha256"))?,
2223            preview_png_sha256: self.preview_png_sha256,
2224            page_count: self
2225                .page_count
2226                .ok_or(DocumentExtensionError::MissingField("page_count"))?,
2227            audit_session_id: self
2228                .audit_session_id
2229                .ok_or(DocumentExtensionError::MissingField("audit_session_id"))?,
2230            clean_spans: self.clean_spans,
2231            codec_audit: self.codec_audit,
2232        })
2233    }
2234}
2235
2236/// Errors returned while building a [`DocumentExtension`].
2237#[derive(Debug, Clone, PartialEq, Eq, Error)]
2238#[non_exhaustive]
2239pub enum DocumentExtensionError {
2240    #[error("missing document extension field: {0}")]
2241    MissingField(&'static str),
2242}
2243
2244/// Provenance of text extracted from a document or transcript source.
2245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2246#[serde(rename_all = "snake_case")]
2247#[non_exhaustive]
2248pub enum TextOrigin {
2249    /// Text came from OCR over pixels.
2250    Ocr,
2251    /// Text came from an embedded text layer.
2252    EmbeddedText,
2253    /// Text came from an audio/video transcript.
2254    Transcript,
2255    /// Text came from multiple extraction paths.
2256    Hybrid,
2257}
2258
2259/// Orthogonal document codec capabilities delivered or advertised by a codec.
2260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2261#[non_exhaustive]
2262pub struct CodecCapabilitySet {
2263    /// Codec can emit text.
2264    pub text: bool,
2265    /// Codec can emit layout geometry.
2266    pub layout: bool,
2267    /// Codec can emit confidence buckets.
2268    pub confidence: bool,
2269    /// Codec can emit timestamps.
2270    pub timestamps: bool,
2271}
2272
2273impl CodecCapabilitySet {
2274    /// Text-only capability set.
2275    pub const TEXT_ONLY: Self = Self {
2276        text: true,
2277        layout: false,
2278        confidence: false,
2279        timestamps: false,
2280    };
2281
2282    /// Builds a codec capability bitset.
2283    pub const fn new(text: bool, layout: bool, confidence: bool, timestamps: bool) -> Self {
2284        Self {
2285            text,
2286            layout,
2287            confidence,
2288            timestamps,
2289        }
2290    }
2291
2292    /// Returns true when this set contains every requested capability bit.
2293    pub fn contains(self, requested: Self) -> bool {
2294        (!requested.text || self.text)
2295            && (!requested.layout || self.layout)
2296            && (!requested.confidence || self.confidence)
2297            && (!requested.timestamps || self.timestamps)
2298    }
2299}
2300
2301/// Per-codec declaration for text extraction density checks.
2302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2303#[serde(rename_all = "snake_case")]
2304#[non_exhaustive]
2305pub enum ExtractionDensityPolicy {
2306    /// Require at least this many extracted text bytes per source KiB.
2307    Required(f32),
2308    /// Explicit exemption with an audit-visible reason.
2309    Exempt { reason: String },
2310}
2311
2312impl Default for ExtractionDensityPolicy {
2313    fn default() -> Self {
2314        Self::Exempt {
2315            reason: "calibration_pending".to_string(),
2316        }
2317    }
2318}
2319
2320/// Metadata-only audit row emitted by a document codec.
2321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2322#[non_exhaustive]
2323pub struct CodecAuditRow {
2324    /// Stable codec id, such as `gaze.codec.tesseract`.
2325    pub codec_id: String,
2326    /// Adapter crate version, distinct from engine provenance.
2327    pub codec_version: String,
2328    /// Accepted MIME type for the decode.
2329    pub accepted_mime: String,
2330    /// Capabilities advertised by the codec.
2331    pub advertised: CodecCapabilitySet,
2332    /// Capabilities delivered for this decode.
2333    pub delivered: CodecCapabilitySet,
2334    /// Text provenance reported by the codec.
2335    pub text_origin: TextOrigin,
2336    /// Codec-output schema version, decoupled from bundle schema version.
2337    pub codec_output_schema_version: u16,
2338    /// Hash of canonical codec options, never the options themselves.
2339    #[serde(default, skip_serializing_if = "Option::is_none")]
2340    pub options_hash_hex: Option<String>,
2341    /// Engine provenance string, without paths or raw source text.
2342    #[serde(default, skip_serializing_if = "Option::is_none")]
2343    pub engine_provenance: Option<String>,
2344    /// Extraction density policy declared by the codec for this MIME.
2345    pub extraction_density_policy: ExtractionDensityPolicy,
2346}
2347
2348impl CodecAuditRow {
2349    /// Builds a metadata-only codec audit row.
2350    pub fn new(
2351        codec_id: impl Into<String>,
2352        codec_version: impl Into<String>,
2353        accepted_mime: impl Into<String>,
2354        text_origin: TextOrigin,
2355    ) -> Self {
2356        Self {
2357            codec_id: codec_id.into(),
2358            codec_version: codec_version.into(),
2359            accepted_mime: accepted_mime.into(),
2360            advertised: CodecCapabilitySet::default(),
2361            delivered: CodecCapabilitySet::default(),
2362            text_origin,
2363            codec_output_schema_version: 1,
2364            options_hash_hex: None,
2365            engine_provenance: None,
2366            extraction_density_policy: ExtractionDensityPolicy::default(),
2367        }
2368    }
2369}
2370
2371/// A suspected missed PII span reported by a [`SafetyNet`].
2372///
2373/// The safety net is not authoritative; a `LeakReport` is a signal, not a confirmed
2374/// leak. False positives are expected. Review reports and adjust policy or recognizer
2375/// thresholds.
2376#[derive(Debug, Clone, Default, PartialEq)]
2377#[non_exhaustive]
2378pub struct LeakReport {
2379    /// Suspected leaks, containing metadata only.
2380    pub suspects: Vec<LeakSuspect>,
2381    /// Bytes-free telemetry events.
2382    pub telemetry: Vec<LeakReportTelemetry>,
2383    /// Aggregated counts for callers that do not need full suspect metadata.
2384    pub stats: LeakReportStats,
2385    /// Optional replay hash.
2386    ///
2387    /// Replay determinism is guaranteed only when command path, checkpoint,
2388    /// operating point, min score, and decode parameters are fixed externally.
2389    pub replay_hash: Option<String>,
2390}
2391
2392impl LeakReport {
2393    /// Builds a report from suspects and telemetry.
2394    pub fn from_parts(
2395        suspects: Vec<LeakSuspect>,
2396        telemetry: Vec<LeakReportTelemetry>,
2397    ) -> LeakReport {
2398        let mut stats = LeakReportStats {
2399            suspect_count: suspects.len(),
2400            locale_skipped_count: telemetry
2401                .iter()
2402                .filter(|event| matches!(event, LeakReportTelemetry::LocaleSkipped { .. }))
2403                .count(),
2404            ..LeakReportStats::default()
2405        };
2406        for suspect in &suspects {
2407            match suspect.kind {
2408                LeakKind::Uncovered => stats.uncovered_count += 1,
2409                LeakKind::PartialBleed { .. } => stats.partial_bleed_count += 1,
2410                LeakKind::ClassMismatch { .. } => stats.class_mismatch_count += 1,
2411            }
2412        }
2413        LeakReport {
2414            suspects,
2415            telemetry,
2416            stats,
2417            replay_hash: None,
2418        }
2419    }
2420
2421    /// Merges another report into this report.
2422    pub fn extend(&mut self, other: LeakReport) {
2423        self.suspects.extend(other.suspects);
2424        self.telemetry.extend(other.telemetry);
2425        *self = LeakReport::from_parts(
2426            std::mem::take(&mut self.suspects),
2427            std::mem::take(&mut self.telemetry),
2428        );
2429    }
2430}
2431
2432/// Closed set of upstream OpenAI Privacy Filter labels accepted by Gaze.
2433#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2434#[non_exhaustive]
2435pub enum OpenAiPrivateLabel {
2436    /// `private_person`.
2437    PrivatePerson,
2438    /// `private_address`.
2439    PrivateAddress,
2440    /// `private_email`.
2441    PrivateEmail,
2442    /// `private_phone`.
2443    PrivatePhone,
2444    /// `private_url`.
2445    PrivateUrl,
2446    /// `private_date`.
2447    PrivateDate,
2448    /// `account_number`.
2449    AccountNumber,
2450    /// `secret`.
2451    Secret,
2452}
2453
2454impl OpenAiPrivateLabel {
2455    /// Returns the raw upstream label.
2456    pub fn as_str(self) -> &'static str {
2457        match self {
2458            Self::PrivatePerson => "private_person",
2459            Self::PrivateAddress => "private_address",
2460            Self::PrivateEmail => "private_email",
2461            Self::PrivatePhone => "private_phone",
2462            Self::PrivateUrl => "private_url",
2463            Self::PrivateDate => "private_date",
2464            Self::AccountNumber => "account_number",
2465            Self::Secret => "secret",
2466        }
2467    }
2468}
2469
2470/// True when byte offset `at` in `text` sits between two alphanumeric characters, so a span
2471/// edge there would cut a word.
2472///
2473/// This is the one word rule every safety-net stage shares: model decoders assemble spans from
2474/// whole words with it, and the pipeline refuses to act on a word-like suspect whose edge it
2475/// flags. Offsets that are out of range or not on a character boundary return false.
2476pub fn is_inside_word(text: &str, at: usize) -> bool {
2477    let before = text.get(..at).and_then(|head| head.chars().next_back());
2478    let after = text.get(at..).and_then(|tail| tail.chars().next());
2479    before.is_some_and(is_word_char) && after.is_some_and(is_word_char)
2480}
2481
2482/// `span` grown outward until neither edge cuts a word under [`is_inside_word`].
2483///
2484/// A model reports spans on sub-word pieces; an edge between two pieces leaves the rest of the
2485/// word raw (`jorunn vas` of `jorunn vasquez`). Out-of-range or off-boundary spans come back
2486/// unchanged.
2487pub fn expand_to_word_edges(text: &str, span: Range<usize>) -> Range<usize> {
2488    if span.start > span.end
2489        || !text.is_char_boundary(span.start)
2490        || !text.is_char_boundary(span.end)
2491    {
2492        return span;
2493    }
2494    let mut start = span.start;
2495    while is_inside_word(text, start) {
2496        start -= text[..start].chars().next_back().map_or(0, char::len_utf8);
2497    }
2498    let mut end = span.end;
2499    while is_inside_word(text, end) {
2500        end += text[end..].chars().next().map_or(0, char::len_utf8);
2501    }
2502    start..end
2503}
2504
2505/// `span` grown over the name parts glued to it by a hyphen or apostrophe.
2506///
2507/// A double surname or a particle is one name: a copy or model span that stops at
2508/// `Vasquez` in `Vasquez-Ellery` or starts at `Brien` in `O'Brien` leaves the rest raw.
2509/// Forward, a continuation needs at least two letters, so a possessive `'s` is not taken.
2510/// Backward, one letter is enough (`O'`, `d'`). A continuation that runs into a digit is not
2511/// a name part and is not taken. Out-of-range or off-boundary spans come back unchanged.
2512pub fn extend_over_name_joiners(text: &str, span: Range<usize>) -> Range<usize> {
2513    const JOINERS: [char; 3] = ['-', '\'', '\u{2019}'];
2514    if span.start > span.end
2515        || !text.is_char_boundary(span.start)
2516        || !text.is_char_boundary(span.end)
2517    {
2518        return span;
2519    }
2520    let mut end = span.end;
2521    loop {
2522        let mut rest = text[end..].chars();
2523        if !rest.next().is_some_and(|ch| JOINERS.contains(&ch)) {
2524            break;
2525        }
2526        let letters = rest.take_while(|ch| ch.is_alphabetic()).collect::<Vec<_>>();
2527        let joiner_len = text[end..].chars().next().map_or(0, char::len_utf8);
2528        let next = end + joiner_len + letters.iter().map(|ch| ch.len_utf8()).sum::<usize>();
2529        if letters.len() < 2 || is_inside_word(text, next) {
2530            break;
2531        }
2532        end = next;
2533    }
2534    let mut start = span.start;
2535    loop {
2536        let mut back = text[..start].chars().rev();
2537        if !back.next().is_some_and(|ch| JOINERS.contains(&ch)) {
2538            break;
2539        }
2540        let letters = back.take_while(|ch| ch.is_alphabetic()).collect::<Vec<_>>();
2541        let joiner_len = text[..start].chars().next_back().map_or(0, char::len_utf8);
2542        let prev = start - joiner_len - letters.iter().map(|ch| ch.len_utf8()).sum::<usize>();
2543        if letters.is_empty() || is_inside_word(text, prev) {
2544            break;
2545        }
2546        start = prev;
2547    }
2548    start..end
2549}
2550
2551/// The one word-character rule behind [`is_inside_word`] and
2552/// [`word_run_extends_identifier`]: Unicode alphanumeric. Both edge checks must agree on what a
2553/// word is, so neither may grow its own predicate.
2554fn is_word_char(ch: char) -> bool {
2555    ch.is_alphanumeric()
2556}
2557
2558/// True when the word run starting at byte offset `at` in `text` could be more of an identifier:
2559/// it holds a digit (Unicode numeric), an underscore, or any other word character that is not a
2560/// letter. A run of letters only (`BIC`, `und`, `Überweisung`) is a label or a word glued to the
2561/// value, not more value. An empty run, an offset out of range, or one not on a character
2562/// boundary returns false.
2563///
2564/// This is the trailing boundary for identifier recognizers whose pattern must not end in `\b`:
2565/// `iban.structural` matches a registry-length candidate without a trailing
2566/// boundary so a compact IBAN glued to the next label (`IBAN AT611904300234573201BIC`) is still a
2567/// candidate, and this decides whether what follows is a label or the rest of a longer opaque
2568/// token (`AT611904300234573201XQ7`). Rust `regex` has no lookahead, so it cannot be said in the
2569/// pattern. The word run reads the same characters as [`is_inside_word`] plus `_`, which joins
2570/// identifiers and which `\b` counts as a word character; a combining mark is not a word
2571/// character to either check and ends the run.
2572pub fn word_run_extends_identifier(text: &str, at: usize) -> bool {
2573    text.get(at..).is_some_and(|tail| {
2574        tail.chars()
2575            .take_while(|ch| is_word_char(*ch) || *ch == '_')
2576            .any(|ch| !ch.is_alphabetic())
2577    })
2578}
2579
2580/// Closed safety-net PII vocabulary before mapping into `PiiClass`.
2581#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2582#[non_exhaustive]
2583pub enum SafetyNetPiiClass {
2584    /// Email address.
2585    Email,
2586    /// Person name.
2587    Name,
2588    /// Location or address.
2589    Location,
2590    /// Phone number.
2591    Phone,
2592    /// URL.
2593    Url,
2594    /// Date.
2595    Date,
2596    /// Account number.
2597    AccountNumber,
2598    /// Secret.
2599    Secret,
2600    /// Building or house number.
2601    BuildingNumber,
2602    /// Vehicle licence plate.
2603    LicensePlate,
2604    /// Account username or handle.
2605    Username,
2606    /// Postal or ZIP code.
2607    PostalCode,
2608    /// Tax identification number.
2609    TaxId,
2610}
2611
2612impl SafetyNetPiiClass {
2613    /// Maps the safety-net class into the shared pipeline class vocabulary.
2614    pub fn to_pii_class(self) -> PiiClass {
2615        match self {
2616            Self::Email => PiiClass::Email,
2617            Self::Name => PiiClass::Name,
2618            Self::Location => PiiClass::Location,
2619            Self::Phone => PiiClass::custom("phone").expect("valid custom class"),
2620            Self::Url => PiiClass::custom("url").expect("valid custom class"),
2621            Self::Date => PiiClass::custom("date").expect("valid custom class"),
2622            Self::AccountNumber => PiiClass::custom("account_number").expect("valid custom class"),
2623            Self::Secret => PiiClass::custom("secret").expect("valid custom class"),
2624            Self::BuildingNumber => {
2625                PiiClass::custom("building_number").expect("valid custom class")
2626            }
2627            Self::LicensePlate => PiiClass::custom("license_plate").expect("valid custom class"),
2628            Self::Username => PiiClass::custom("username").expect("valid custom class"),
2629            Self::PostalCode => PiiClass::custom("postal_code").expect("valid custom class"),
2630            Self::TaxId => PiiClass::custom("tax_id").expect("valid custom class"),
2631        }
2632    }
2633}
2634
2635/// Exhaustive, closed error set for safety-net execution.
2636#[derive(Debug, Clone, PartialEq, Eq, Error)]
2637#[non_exhaustive]
2638pub enum SafetyNetError {
2639    /// Safety net was explicitly requested but is unavailable.
2640    #[error("safety net unavailable: {reason}")]
2641    Unavailable {
2642        /// Sanitized reason.
2643        reason: String,
2644    },
2645    /// Required model weights or checkpoint are missing.
2646    #[error("safety net weights missing: {path}")]
2647    WeightsMissing {
2648        /// Sanitized path or identifier.
2649        path: String,
2650    },
2651    /// Backend model could not be loaded or reached.
2652    #[error("safety net model unavailable: {reason}")]
2653    ModelUnavailable {
2654        /// Sanitized reason.
2655        reason: String,
2656    },
2657    /// Backend model artifacts failed integrity verification.
2658    #[error("safety net model integrity mismatch: expected={expected}, actual={actual}")]
2659    ModelIntegrityMismatch {
2660        /// Expected SHA256 digest.
2661        expected: String,
2662        /// Actual SHA256 digest.
2663        actual: String,
2664    },
2665    /// Input exceeded configured backend limit.
2666    #[error("safety net input too large: limit={limit}, actual={actual}")]
2667    InputTooLarge {
2668        /// Configured byte limit.
2669        limit: usize,
2670        /// Actual byte length.
2671        actual: usize,
2672    },
2673    /// Backend runtime failed.
2674    #[error("safety net runtime failed: {message}")]
2675    Runtime {
2676        /// Sanitized diagnostic message.
2677        message: String,
2678    },
2679    /// Backend returned invalid output.
2680    #[error("safety net invalid output: {message}")]
2681    InvalidOutput {
2682        /// Sanitized diagnostic message.
2683        message: String,
2684    },
2685}
2686
2687/// Disposition applied to a detected PII span.
2688///
2689/// | Variant | Restorable | Output shape |
2690/// |---------|------------|--------------|
2691/// | `Tokenize` | Yes | Opaque token: `<hex:Class_N>` |
2692/// | `FormatPreserve` | Yes | Realistic-looking pseudonym (e.g., `email1.hex@gaze-fake.invalid`) |
2693/// | `Redact` | No | Literal `[REDACTED]` -- original value is gone |
2694/// | `Generalize` | No | Class label (e.g., `[Email]`) -- original value is gone |
2695/// | `Preserve` | - | Passes through unchanged |
2696///
2697/// `Action` is `#[non_exhaustive]`. Use a wildcard arm in exhaustive matches.
2698/// When restore is required, use `Tokenize` or `FormatPreserve` -- `Redact` and
2699/// `Generalize` are irreversible.
2700///
2701/// Serialized with the canonical audit-row spelling (`tokenize`, `redact`,
2702/// `format_preserve`, `generalize`, `preserve`), the same strings as
2703/// [`Action::as_str`].
2704#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2705#[non_exhaustive]
2706#[serde(rename_all = "snake_case")]
2707pub enum Action {
2708    /// Replace PII with a reversible token.
2709    Tokenize,
2710    /// Replace PII with a non-restorable redaction marker.
2711    Redact,
2712    /// Replace PII with a reversible format-preserving token.
2713    FormatPreserve,
2714    /// Replace PII with a broader category.
2715    Generalize,
2716    /// Preserve the original value.
2717    Preserve,
2718}
2719
2720impl Action {
2721    /// Returns the canonical audit-row spelling.
2722    pub fn as_str(&self) -> &'static str {
2723        match self {
2724            Self::Tokenize => "tokenize",
2725            Self::Redact => "redact",
2726            Self::FormatPreserve => "format_preserve",
2727            Self::Generalize => "generalize",
2728            Self::Preserve => "preserve",
2729        }
2730    }
2731
2732    /// Parses a canonical audit-row spelling.
2733    pub fn from_canonical_str(value: &str) -> Option<Self> {
2734        match value {
2735            "tokenize" => Some(Self::Tokenize),
2736            "redact" => Some(Self::Redact),
2737            "format_preserve" => Some(Self::FormatPreserve),
2738            "generalize" => Some(Self::Generalize),
2739            "preserve" => Some(Self::Preserve),
2740            _ => None,
2741        }
2742    }
2743
2744    /// Position in the fail-closed strictness order, higher is stricter:
2745    ///
2746    /// | Rank | Action | Original bytes in output | Restorable |
2747    /// |------|--------|--------------------------|------------|
2748    /// | 4 | `Redact` | none | no |
2749    /// | 3 | `Tokenize` | none | yes |
2750    /// | 2 | `Generalize` | none (class label only) | no |
2751    /// | 1 | `FormatPreserve` | none (class-shaped fake) | yes |
2752    /// | 0 | `Preserve` | all | - |
2753    ///
2754    /// The order is used when a collision-family token
2755    /// (`custom:family:<name>`) has no explicit policy rule and takes the
2756    /// strictest action among its member classes' rules and its own default.
2757    /// Between two actions that leak no original byte, the non-restorable one
2758    /// ranks higher: an adopter who redacts one member does not want that value
2759    /// restored downstream, and redacting the other member only costs
2760    /// restorability (axis 2), never a leak (axis 1). `Generalize` ranks above
2761    /// `FormatPreserve` on the same tie-break. An explicit family rule overrides
2762    /// the derivation.
2763    pub const fn strictness_rank(self) -> u8 {
2764        match self {
2765            Self::Redact => 4,
2766            Self::Tokenize => 3,
2767            Self::Generalize => 2,
2768            Self::FormatPreserve => 1,
2769            Self::Preserve => 0,
2770        }
2771    }
2772
2773    /// The stricter of two actions under [`Action::strictness_rank`]; `self`
2774    /// wins a tie.
2775    pub fn strictest(self, other: Self) -> Self {
2776        if other.strictness_rank() > self.strictness_rank() {
2777            other
2778        } else {
2779            self
2780        }
2781    }
2782
2783    /// Whether the action replaces the span it applies to: every action but
2784    /// `Preserve` (the actions ranked above 0 by [`Action::strictness_rank`]).
2785    ///
2786    /// Residual coverage admits a losing candidate's evidence only when every
2787    /// action in its overlap component is protective. The gate used to be "is
2788    /// exactly `tokenize`", which let a stricter action on a neighbour, or on
2789    /// the family class a loser resolves through, silently drop coverage of
2790    /// the loser's remaining bytes.
2791    pub const fn is_protective(self) -> bool {
2792        match self {
2793            Self::Tokenize | Self::Redact | Self::Generalize | Self::FormatPreserve => true,
2794            Self::Preserve => false,
2795        }
2796    }
2797}
2798
2799/// Conflict resolution tier that selected or rejected a candidate.
2800#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2801#[non_exhaustive]
2802pub enum ConflictTier {
2803    /// No conflict resolution was needed.
2804    None,
2805    /// Class priority decided the conflict.
2806    ClassPriority,
2807    /// Rule priority decided the conflict.
2808    RulePriority,
2809    /// Candidate score decided the conflict.
2810    Score,
2811    /// Span length decided the conflict.
2812    SpanLength,
2813    /// Same-class containment validator result decided the conflict.
2814    Validator,
2815    /// Strict same-class containment selected the span protecting more bytes.
2816    SameClassContainment,
2817    /// Pre-resolver validator veto rejected the candidate.
2818    ValidatorVeto,
2819    /// Cross-class collision-family policy decided the conflict.
2820    CollisionPolicy,
2821    /// Mandatory-anchor context was missing, so family-level fallback was emitted.
2822    AnchoredContext,
2823    /// A builtin-class span (`Email`/`Name`/`Organization`/`Location`) strictly
2824    /// inside a custom-class structured span lost to the enclosing span, so a
2825    /// structured identifier is never split around a sub-token.
2826    StructuredContainment,
2827    /// A candidate that wholly contains a candidate of another class won the
2828    /// whole span as one token: its evidence tier (validator passed >
2829    /// anchored or structural cue > plain regex or dictionary > learned NER)
2830    /// is at least the contained candidate's, ties going to the container.
2831    /// The contained candidate is recorded as a merged source.
2832    ContainmentPrecedence,
2833    /// A residual fragment replaced bytes inside a `preserve` selection
2834    /// because a class the policy protects also claimed them: protection beat
2835    /// preservation on exactly those bytes.
2836    ProtectionOverride,
2837    /// Recognizer identifier decided the conflict.
2838    RecognizerId,
2839    /// Candidate was merged with another candidate.
2840    Merged,
2841    /// Safety-net redact mode stripped a suspect span.
2842    Redact,
2843    /// Safety-net resolve mode promoted a suspect span into a reversible token.
2844    Resolve,
2845    /// Safety-net fallback policy decided the outcome.
2846    Fallback,
2847    /// The repeat-value sweep protected a copy of a value the manifest
2848    /// already holds from rule-based evidence.
2849    ManifestSweep,
2850}
2851
2852impl ConflictTier {
2853    /// Every variant, for closed-vocabulary checks (component ledger records).
2854    #[doc(hidden)]
2855    pub const ALL: &'static [Self] = &[
2856        Self::None,
2857        Self::ClassPriority,
2858        Self::RulePriority,
2859        Self::Score,
2860        Self::SpanLength,
2861        Self::Validator,
2862        Self::SameClassContainment,
2863        Self::ValidatorVeto,
2864        Self::CollisionPolicy,
2865        Self::AnchoredContext,
2866        Self::StructuredContainment,
2867        Self::ContainmentPrecedence,
2868        Self::ProtectionOverride,
2869        Self::RecognizerId,
2870        Self::Merged,
2871        Self::Redact,
2872        Self::Resolve,
2873        Self::Fallback,
2874        Self::ManifestSweep,
2875    ];
2876
2877    /// Fails to compile when a variant is added without extending [`Self::ALL`].
2878    #[allow(dead_code)]
2879    const fn all_is_exhaustive(value: Self) {
2880        match value {
2881            Self::None
2882            | Self::ClassPriority
2883            | Self::RulePriority
2884            | Self::Score
2885            | Self::SpanLength
2886            | Self::Validator
2887            | Self::SameClassContainment
2888            | Self::ValidatorVeto
2889            | Self::CollisionPolicy
2890            | Self::AnchoredContext
2891            | Self::StructuredContainment
2892            | Self::ContainmentPrecedence
2893            | Self::ProtectionOverride
2894            | Self::RecognizerId
2895            | Self::Merged
2896            | Self::Redact
2897            | Self::Resolve
2898            | Self::Fallback
2899            | Self::ManifestSweep => {}
2900        }
2901    }
2902
2903    /// Returns the canonical audit-row spelling.
2904    pub fn as_str(&self) -> &'static str {
2905        match self {
2906            Self::None => "none",
2907            Self::ClassPriority => "class_priority",
2908            Self::RulePriority => "rule_priority",
2909            Self::Score => "score",
2910            Self::SpanLength => "span_length",
2911            Self::Validator => "validator",
2912            Self::SameClassContainment => "same_class_containment",
2913            Self::ValidatorVeto => "validator_veto",
2914            Self::CollisionPolicy => "collision_policy",
2915            Self::AnchoredContext => "anchored_context",
2916            Self::StructuredContainment => "structured_containment",
2917            Self::ContainmentPrecedence => "containment_precedence",
2918            Self::ProtectionOverride => "protection_override",
2919            Self::RecognizerId => "recognizer_id",
2920            Self::Merged => "merged",
2921            Self::Redact => "redact",
2922            Self::Resolve => "resolve",
2923            Self::Fallback => "fallback",
2924            Self::ManifestSweep => "manifest_sweep",
2925        }
2926    }
2927
2928    /// Parses a canonical audit-row spelling.
2929    pub fn from_canonical_str(value: &str) -> Option<Self> {
2930        match value {
2931            "none" => Some(Self::None),
2932            "class_priority" => Some(Self::ClassPriority),
2933            "rule_priority" => Some(Self::RulePriority),
2934            "score" => Some(Self::Score),
2935            "span_length" => Some(Self::SpanLength),
2936            "validator" => Some(Self::Validator),
2937            "same_class_containment" => Some(Self::SameClassContainment),
2938            "validator_veto" => Some(Self::ValidatorVeto),
2939            "collision_policy" => Some(Self::CollisionPolicy),
2940            "anchored_context" => Some(Self::AnchoredContext),
2941            "structured_containment" => Some(Self::StructuredContainment),
2942            "containment_precedence" => Some(Self::ContainmentPrecedence),
2943            "protection_override" => Some(Self::ProtectionOverride),
2944            "recognizer_id" => Some(Self::RecognizerId),
2945            "merged" => Some(Self::Merged),
2946            "redact" => Some(Self::Redact),
2947            "resolve" => Some(Self::Resolve),
2948            "fallback" => Some(Self::Fallback),
2949            "manifest_sweep" => Some(Self::ManifestSweep),
2950            _ => None,
2951        }
2952    }
2953}
2954
2955/// Safety-net fallback reason recorded in metadata-only audit rows.
2956#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2957#[non_exhaustive]
2958#[serde(rename_all = "snake_case")]
2959pub enum FallbackReason {
2960    /// The suspect overlapped an existing emitted token in a way that could not be promoted.
2961    #[serde(alias = "OverlapConflict")]
2962    OverlapConflict,
2963    /// A validator rejected the promoted candidate.
2964    #[serde(alias = "ValidatorVeto")]
2965    ValidatorVeto,
2966    /// A mandatory anchor was missing for the promoted candidate.
2967    #[serde(alias = "AnchorMissing")]
2968    AnchorMissing,
2969    /// A follow-up safety-net pass still observed a suspect.
2970    #[serde(alias = "ResidualSuspect")]
2971    ResidualSuspect,
2972}
2973
2974impl FallbackReason {
2975    /// Returns the canonical audit-row and JSON spelling.
2976    pub fn as_str(&self) -> &'static str {
2977        match self {
2978            Self::OverlapConflict => "overlap_conflict",
2979            Self::ValidatorVeto => "validator_veto",
2980            Self::AnchorMissing => "anchor_missing",
2981            Self::ResidualSuspect => "residual_suspect",
2982        }
2983    }
2984
2985    /// Parses a canonical audit-row spelling.
2986    pub fn from_canonical_str(value: &str) -> Option<Self> {
2987        match value {
2988            "overlap_conflict" => Some(Self::OverlapConflict),
2989            "validator_veto" => Some(Self::ValidatorVeto),
2990            "anchor_missing" => Some(Self::AnchorMissing),
2991            "residual_suspect" => Some(Self::ResidualSuspect),
2992            _ => None,
2993        }
2994    }
2995}
2996
2997/// Source document kind for metadata-only audit logging.
2998#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2999#[non_exhaustive]
3000pub enum DocumentKind {
3001    /// Structured key/value document.
3002    Structured,
3003    /// Plain text document.
3004    Text,
3005}
3006
3007impl DocumentKind {
3008    /// Returns the canonical audit-row spelling.
3009    pub fn as_str(&self) -> &'static str {
3010        match self {
3011            Self::Structured => "structured",
3012            Self::Text => "text",
3013        }
3014    }
3015
3016    /// Parses a canonical audit-row spelling.
3017    pub fn from_canonical_str(value: &str) -> Option<Self> {
3018        match value {
3019            "structured" => Some(Self::Structured),
3020            "text" => Some(Self::Text),
3021            _ => None,
3022        }
3023    }
3024}
3025
3026/// One row of redaction metadata emitted to a [`RedactionLogger`].
3027///
3028/// Fields identify the PII class, action taken, session ID, source document kind,
3029/// conflict-resolution metadata, and timestamp. Does **not** contain the original PII
3030/// value, the token string, or any identifiable content beyond what a compliance audit
3031/// requires.
3032///
3033/// `RedactionEntry` is `#[non_exhaustive]`; adopters must construct via the public
3034/// constructor or destructure with a wildcard pattern.
3035#[derive(Debug, Clone, PartialEq, Eq)]
3036#[non_exhaustive]
3037pub struct RedactionEntry {
3038    /// Detector or recognizer source identifier.
3039    pub source: String,
3040    /// Stable semantic recognizer identifier, when available.
3041    pub recognizer_id: Option<String>,
3042    /// Versioned recognizer artifact/rule identifier, when available.
3043    pub recognizer_version_id: Option<String>,
3044    /// PII class affected by the decision.
3045    pub class: PiiClass,
3046    /// Policy action applied to the span.
3047    pub action: Action,
3048    /// Optional structured field name.
3049    pub field_name: Option<String>,
3050    /// Source document kind.
3051    pub document_kind: DocumentKind,
3052    /// Whether this entry records a loser in conflict resolution.
3053    pub conflict_loser: bool,
3054    /// Conflict tier that decided the outcome.
3055    pub decided_by: ConflictTier,
3056    /// Creation timestamp in epoch milliseconds.
3057    pub created_at: i64,
3058    /// Optional session identifier.
3059    pub session_id: Option<String>,
3060    /// Optional validator or regex-guard refusal reason for a vetoed candidate.
3061    pub validator_fail_reason: Option<ValidatorFailReason>,
3062    /// Closed reason for a labelled value boundary or length limit.
3063    pub labelled_value_scan_reason: Option<LabelledValueScanReason>,
3064    /// Optional ambiguity metadata for a family-level fallback.
3065    pub ambiguity_record: Option<AmbiguityRecord>,
3066    /// Collision family that influenced this decision.
3067    pub collision_family: Option<String>,
3068    /// Collision variant that influenced this decision.
3069    pub collision_variant: Option<String>,
3070    /// Safety-net fallback reason, when fallback policy handled the row.
3071    pub fallback_triggered: Option<FallbackReason>,
3072    /// NER/pipeline provenance stage for audit-only producer attribution.
3073    pub provenance_stage: Option<String>,
3074    pub provenance_model_id: Option<String>,
3075    pub provenance_model_version: Option<String>,
3076    pub provenance_artifact_sha256: Option<String>,
3077    pub provenance_tokenizer_sha256: Option<String>,
3078    pub provenance_locale_resolved: Option<String>,
3079    pub provenance_locale_match_kind: Option<String>,
3080    pub provenance_canonical_class: Option<String>,
3081    pub provenance_native_class: Option<String>,
3082    pub provenance_confidence: Option<String>,
3083    pub provenance_merged_from: Option<String>,
3084    /// Locale-aware safety-net backend ids dropped by first-match-wins routing.
3085    pub backend_silently_dropped: Option<Vec<String>>,
3086    pub restore_policy: Option<String>,
3087    pub restore_decision: Option<String>,
3088    pub restore_unknown_token_count: Option<u64>,
3089    pub restore_manifest_bypass_count: Option<u64>,
3090    pub restore_fresh_pii_count: Option<u64>,
3091    pub restore_phase_mask: Option<u32>,
3092    pub restore_trap_shape_count: Option<u64>,
3093}
3094
3095impl Serialize for RedactionEntry {
3096    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
3097    where
3098        S: serde::Serializer,
3099    {
3100        use serde::ser::SerializeStruct;
3101
3102        let mut len = 14;
3103        if self.recognizer_id.is_some() {
3104            len += 1;
3105        }
3106        if self.recognizer_version_id.is_some() {
3107            len += 1;
3108        }
3109        len += [
3110            self.provenance_stage.as_ref(),
3111            self.provenance_model_id.as_ref(),
3112            self.provenance_model_version.as_ref(),
3113            self.provenance_artifact_sha256.as_ref(),
3114            self.provenance_tokenizer_sha256.as_ref(),
3115            self.provenance_locale_resolved.as_ref(),
3116            self.provenance_locale_match_kind.as_ref(),
3117            self.provenance_canonical_class.as_ref(),
3118            self.provenance_native_class.as_ref(),
3119            self.provenance_confidence.as_ref(),
3120            self.provenance_merged_from.as_ref(),
3121        ]
3122        .into_iter()
3123        .filter(|value| value.is_some())
3124        .count();
3125        if self.backend_silently_dropped.is_some() {
3126            len += 1;
3127        }
3128        len += [self.restore_policy.as_ref(), self.restore_decision.as_ref()]
3129            .into_iter()
3130            .filter(|value| value.is_some())
3131            .count();
3132        len += [
3133            self.restore_unknown_token_count.is_some(),
3134            self.restore_manifest_bypass_count.is_some(),
3135            self.restore_fresh_pii_count.is_some(),
3136            self.restore_phase_mask.is_some(),
3137            self.restore_trap_shape_count.is_some(),
3138        ]
3139        .into_iter()
3140        .filter(|value| *value)
3141        .count();
3142        let mut state = serializer.serialize_struct("RedactionEntry", len)?;
3143        state.serialize_field("source", &self.source)?;
3144        if let Some(recognizer_id) = &self.recognizer_id {
3145            state.serialize_field("recognizer_id", recognizer_id)?;
3146        }
3147        if let Some(recognizer_version_id) = &self.recognizer_version_id {
3148            state.serialize_field("recognizer_version_id", recognizer_version_id)?;
3149        }
3150        state.serialize_field("class", &self.class.to_canonical_str())?;
3151        state.serialize_field("action", self.action.as_str())?;
3152        state.serialize_field("field_name", &self.field_name)?;
3153        state.serialize_field("document_kind", self.document_kind.as_str())?;
3154        state.serialize_field("conflict_loser", &self.conflict_loser)?;
3155        state.serialize_field("decided_by", self.decided_by.as_str())?;
3156        state.serialize_field("created_at", &self.created_at)?;
3157        state.serialize_field("session_id", &self.session_id)?;
3158        state.serialize_field("validator_fail_reason", &self.validator_fail_reason)?;
3159        state.serialize_field("ambiguity_record", &self.ambiguity_record)?;
3160        state.serialize_field("collision_family", &self.collision_family)?;
3161        state.serialize_field("collision_variant", &self.collision_variant)?;
3162        state.serialize_field("fallback_triggered", &self.fallback_triggered)?;
3163        if let Some(value) = &self.provenance_stage {
3164            state.serialize_field("provenance_stage", value)?;
3165        }
3166        if let Some(value) = &self.provenance_model_id {
3167            state.serialize_field("provenance_model_id", value)?;
3168        }
3169        if let Some(value) = &self.provenance_model_version {
3170            state.serialize_field("provenance_model_version", value)?;
3171        }
3172        if let Some(value) = &self.provenance_artifact_sha256 {
3173            state.serialize_field("provenance_artifact_sha256", value)?;
3174        }
3175        if let Some(value) = &self.provenance_tokenizer_sha256 {
3176            state.serialize_field("provenance_tokenizer_sha256", value)?;
3177        }
3178        if let Some(value) = &self.provenance_locale_resolved {
3179            state.serialize_field("provenance_locale_resolved", value)?;
3180        }
3181        if let Some(value) = &self.provenance_locale_match_kind {
3182            state.serialize_field("provenance_locale_match_kind", value)?;
3183        }
3184        if let Some(value) = &self.provenance_canonical_class {
3185            state.serialize_field("provenance_canonical_class", value)?;
3186        }
3187        if let Some(value) = &self.provenance_native_class {
3188            state.serialize_field("provenance_native_class", value)?;
3189        }
3190        if let Some(value) = &self.provenance_confidence {
3191            state.serialize_field("provenance_confidence", value)?;
3192        }
3193        if let Some(value) = &self.provenance_merged_from {
3194            state.serialize_field("provenance_merged_from", value)?;
3195        }
3196        if let Some(dropped) = &self.backend_silently_dropped {
3197            state.serialize_field("backend_silently_dropped", dropped)?;
3198        }
3199        if let Some(value) = &self.restore_policy {
3200            state.serialize_field("restore_policy", value)?;
3201        }
3202        if let Some(value) = &self.restore_decision {
3203            state.serialize_field("restore_decision", value)?;
3204        }
3205        if let Some(value) = self.restore_unknown_token_count {
3206            state.serialize_field("restore_unknown_token_count", &value)?;
3207        }
3208        if let Some(value) = self.restore_manifest_bypass_count {
3209            state.serialize_field("restore_manifest_bypass_count", &value)?;
3210        }
3211        if let Some(value) = self.restore_fresh_pii_count {
3212            state.serialize_field("restore_fresh_pii_count", &value)?;
3213        }
3214        if let Some(value) = self.restore_trap_shape_count {
3215            state.serialize_field("restore_trap_shape_count", &value)?;
3216        }
3217        if let Some(value) = self.restore_phase_mask {
3218            state.serialize_field("restore_phase_mask", &value)?;
3219        }
3220        state.end()
3221    }
3222}
3223
3224impl RedactionEntry {
3225    /// Builds a metadata-only redaction log entry.
3226    #[allow(clippy::too_many_arguments)]
3227    pub fn new(
3228        source: impl Into<String>,
3229        class: PiiClass,
3230        action: Action,
3231        field_name: Option<String>,
3232        document_kind: DocumentKind,
3233        conflict_loser: bool,
3234        decided_by: ConflictTier,
3235        created_at: i64,
3236        session_id: Option<String>,
3237    ) -> Self {
3238        Self {
3239            source: source.into(),
3240            class,
3241            action,
3242            field_name,
3243            document_kind,
3244            conflict_loser,
3245            decided_by,
3246            created_at,
3247            session_id,
3248            recognizer_id: None,
3249            recognizer_version_id: None,
3250            validator_fail_reason: None,
3251            labelled_value_scan_reason: None,
3252            ambiguity_record: None,
3253            collision_family: None,
3254            collision_variant: None,
3255            fallback_triggered: None,
3256            provenance_stage: None,
3257            provenance_model_id: None,
3258            provenance_model_version: None,
3259            provenance_artifact_sha256: None,
3260            provenance_tokenizer_sha256: None,
3261            provenance_locale_resolved: None,
3262            provenance_locale_match_kind: None,
3263            provenance_canonical_class: None,
3264            provenance_native_class: None,
3265            provenance_confidence: None,
3266            provenance_merged_from: None,
3267            backend_silently_dropped: None,
3268            restore_policy: None,
3269            restore_decision: None,
3270            restore_unknown_token_count: None,
3271            restore_manifest_bypass_count: None,
3272            restore_fresh_pii_count: None,
3273            restore_phase_mask: None,
3274            restore_trap_shape_count: None,
3275        }
3276    }
3277
3278    /// Attaches a validator failure reason to this metadata row.
3279    pub fn with_validator_fail_reason(mut self, reason: ValidatorFailReason) -> Self {
3280        self.validator_fail_reason = Some(reason);
3281        self
3282    }
3283
3284    /// Attaches an ambiguity record to this metadata row.
3285    pub fn with_ambiguity_record(mut self, record: AmbiguityRecord) -> Self {
3286        self.ambiguity_record = Some(record);
3287        self
3288    }
3289
3290    /// Attaches collision-family metadata to this row.
3291    pub fn with_collision_metadata(
3292        mut self,
3293        family: Option<String>,
3294        variant: Option<String>,
3295    ) -> Self {
3296        self.collision_family = family;
3297        self.collision_variant = variant;
3298        self
3299    }
3300
3301    /// Attaches safety-net fallback metadata to this row.
3302    pub fn with_fallback_triggered(mut self, reason: FallbackReason) -> Self {
3303        self.fallback_triggered = Some(reason);
3304        self
3305    }
3306
3307    /// Attaches locale-aware backend ids dropped by first-match-wins routing.
3308    pub fn with_backend_silently_dropped(mut self, dropped: Vec<String>) -> Self {
3309        self.backend_silently_dropped = Some(dropped);
3310        self
3311    }
3312
3313    pub fn with_restore_telemetry(mut self, telemetry: RestoreTelemetry) -> Self {
3314        self.restore_policy = Some(telemetry.restore_policy_str().to_string());
3315        self.restore_decision = Some(telemetry.restore_decision_str().to_string());
3316        self.restore_unknown_token_count = Some(telemetry.unknown_token_count);
3317        self.restore_manifest_bypass_count = Some(telemetry.manifest_bypass_count);
3318        self.restore_fresh_pii_count = Some(telemetry.fresh_pii_detected_count);
3319        self.restore_phase_mask = Some(telemetry.phase_execution_mask);
3320        self.restore_trap_shape_count = Some(telemetry.trap_shape_count);
3321        self
3322    }
3323
3324    /// Attaches recognizer lineage metadata to this row.
3325    pub fn with_recognizer_metadata(
3326        mut self,
3327        recognizer_id: Option<String>,
3328        recognizer_version_id: Option<String>,
3329    ) -> Self {
3330        self.recognizer_id = recognizer_id;
3331        self.recognizer_version_id = recognizer_version_id;
3332        self
3333    }
3334
3335    #[allow(clippy::too_many_arguments)]
3336    pub fn with_provenance_metadata(
3337        mut self,
3338        stage: Option<String>,
3339        model_id: Option<String>,
3340        model_version: Option<String>,
3341        artifact_sha256: Option<String>,
3342        tokenizer_sha256: Option<String>,
3343        locale_resolved: Option<String>,
3344        locale_match_kind: Option<String>,
3345        canonical_class: Option<String>,
3346        native_class: Option<String>,
3347        confidence: Option<f64>,
3348        merged_from: Option<String>,
3349    ) -> Self {
3350        self.provenance_stage = stage;
3351        self.provenance_model_id = model_id;
3352        self.provenance_model_version = model_version;
3353        self.provenance_artifact_sha256 = artifact_sha256;
3354        self.provenance_tokenizer_sha256 = tokenizer_sha256;
3355        self.provenance_locale_resolved = locale_resolved;
3356        self.provenance_locale_match_kind = locale_match_kind;
3357        self.provenance_canonical_class = canonical_class;
3358        self.provenance_native_class = native_class;
3359        self.provenance_confidence = confidence.map(|value| value.to_string());
3360        self.provenance_merged_from = merged_from;
3361        self
3362    }
3363}
3364
3365/// Closed error set for redaction log sinks.
3366#[derive(Debug, Clone, PartialEq, Eq, Error)]
3367#[non_exhaustive]
3368pub enum RedactionLogError {
3369    /// SQLite-backed redaction log sink failed.
3370    #[error("sqlite redaction log error: {0}")]
3371    Sqlite(String),
3372    /// Non-SQLite redaction log sink failed.
3373    #[error("backend redaction log error: {0}")]
3374    Backend(String),
3375}
3376
3377/// Trait for audit sinks that receive redaction metadata.
3378///
3379/// Implement this for custom audit backends (remote telemetry, structured JSON logs).
3380/// For SQLite-backed persistence, use `gaze_audit::SqliteLogger`.
3381///
3382/// # Contract
3383///
3384/// The logger receives **metadata only**: class, action, session ID, timestamp, and
3385/// other bytes-free audit labels. It never receives the original PII value or the token
3386/// value. A custom impl that augments entries with raw document text violates the audit
3387/// isolation contract and will be flagged by the `gaze_module_isolation` Dylint lint
3388/// when it lives in the wrong crate.
3389///
3390/// # Example
3391///
3392/// ```rust
3393/// use std::sync::atomic::{AtomicUsize, Ordering};
3394/// use gaze_types::{RedactionEntry, RedactionLogError, RedactionLogger};
3395///
3396/// #[derive(Default)]
3397/// struct CountLogger(AtomicUsize);
3398///
3399/// impl RedactionLogger for CountLogger {
3400///     fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
3401///         self.0.fetch_add(1, Ordering::Relaxed);
3402///         Ok(())
3403///     }
3404/// }
3405/// ```
3406pub trait RedactionLogger: Send + Sync {
3407    /// Records a metadata-only redaction entry.
3408    fn log(&self, entry: &RedactionEntry) -> Result<(), RedactionLogError>;
3409}
3410
3411/// Rulepack recognizer activation tier.
3412#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
3413#[non_exhaustive]
3414pub enum SafetyTier {
3415    /// Activates whenever the recognizer's locale projection intersects the active locale chain.
3416    #[default]
3417    SafeDefault,
3418    /// Activates only when an explicit locale or compatibility alias enables locale-shaped rules.
3419    LocaleGated,
3420    /// Activates only through adopter opt-in surfaces such as policy-defined custom recognizers.
3421    OptIn,
3422}
3423
3424/// Safety-tier parsing error.
3425#[derive(Debug, Clone, PartialEq, Eq)]
3426#[non_exhaustive]
3427pub struct SafetyTierParseError {
3428    value: String,
3429}
3430
3431impl SafetyTier {
3432    /// Parses the TOML `safety_tier` string.
3433    pub fn parse(value: &str) -> Result<Self, SafetyTierParseError> {
3434        match value {
3435            "safe_default" => Ok(Self::SafeDefault),
3436            "locale_gated" => Ok(Self::LocaleGated),
3437            "opt_in" => Ok(Self::OptIn),
3438            other => Err(SafetyTierParseError {
3439                value: other.to_string(),
3440            }),
3441        }
3442    }
3443
3444    /// Returns the TOML string for this tier.
3445    pub fn as_str(self) -> &'static str {
3446        match self {
3447            Self::SafeDefault => "safe_default",
3448            Self::LocaleGated => "locale_gated",
3449            Self::OptIn => "opt_in",
3450        }
3451    }
3452}
3453
3454impl SafetyTierParseError {
3455    /// Returns the rejected safety-tier string.
3456    pub fn value(&self) -> &str {
3457        &self.value
3458    }
3459}
3460
3461impl fmt::Display for SafetyTierParseError {
3462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3463        write!(f, "unsupported safety_tier '{}'", self.value)
3464    }
3465}
3466
3467impl std::error::Error for SafetyTierParseError {}
3468
3469/// Interpretation of a rulepack recognizer's locale metadata.
3470#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
3471#[non_exhaustive]
3472pub enum LocaleBasis {
3473    /// `locales` is an eligibility gate matched against the document locale chain.
3474    #[default]
3475    Document,
3476    /// `locales` records identifier-format provenance and never gates eligibility.
3477    Format,
3478}
3479
3480/// Locale-basis parsing error.
3481#[derive(Debug, Clone, PartialEq, Eq)]
3482#[non_exhaustive]
3483pub struct LocaleBasisParseError {
3484    value: String,
3485}
3486
3487impl LocaleBasis {
3488    /// Parses the TOML `locale_basis` string.
3489    pub fn parse(value: &str) -> Result<Self, LocaleBasisParseError> {
3490        match value {
3491            "document" => Ok(Self::Document),
3492            "format" => Ok(Self::Format),
3493            other => Err(LocaleBasisParseError {
3494                value: other.to_string(),
3495            }),
3496        }
3497    }
3498
3499    /// Returns the TOML string for this basis.
3500    pub fn as_str(self) -> &'static str {
3501        match self {
3502            Self::Document => "document",
3503            Self::Format => "format",
3504        }
3505    }
3506}
3507
3508impl LocaleBasisParseError {
3509    /// Returns the rejected locale-basis string.
3510    pub fn value(&self) -> &str {
3511        &self.value
3512    }
3513}
3514
3515impl fmt::Display for LocaleBasisParseError {
3516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3517        write!(f, "unsupported locale_basis '{}'", self.value)
3518    }
3519}
3520
3521impl std::error::Error for LocaleBasisParseError {}
3522
3523/// Locale tag recognized by policy and recognizers.
3524#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3525#[non_exhaustive]
3526pub enum LocaleTag {
3527    /// Locale-independent recognizer or policy.
3528    Global,
3529    /// German as used in Germany.
3530    DeDe,
3531    /// German as used in Austria.
3532    DeAt,
3533    /// German as used in Switzerland.
3534    DeCh,
3535    /// English as used in the United States.
3536    EnUs,
3537    /// English as used in Great Britain.
3538    EnGb,
3539    /// English as used in Ireland.
3540    EnIe,
3541    /// English as used in Australia.
3542    EnAu,
3543    /// English as used in Canada.
3544    EnCa,
3545    /// Any other canonical BCP-47-like tag.
3546    Other(String),
3547}
3548
3549/// Locale parsing error.
3550#[derive(Debug, Clone, PartialEq, Eq)]
3551#[non_exhaustive]
3552pub enum LocaleError {
3553    /// Locale tag is unsupported or invalid.
3554    Unsupported,
3555}
3556
3557impl fmt::Display for LocaleError {
3558    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3559        match self {
3560            LocaleError::Unsupported => f.write_str("unsupported locale"),
3561        }
3562    }
3563}
3564
3565impl std::error::Error for LocaleError {}
3566
3567/// Ordered locale fallback chain.
3568#[derive(Debug, Clone, PartialEq, Eq)]
3569pub struct LocaleChain(Vec<LocaleTag>);
3570
3571impl LocaleTag {
3572    /// Global locale constant.
3573    pub const GLOBAL: LocaleTag = LocaleTag::Global;
3574
3575    /// Parses a locale tag from policy or CLI input.
3576    pub fn parse(s: &str) -> Result<LocaleTag, LocaleError> {
3577        let raw = s.trim().replace('_', "-");
3578        let normalized = raw.to_ascii_lowercase();
3579        match normalized.as_str() {
3580            "global" | "*" => Ok(LocaleTag::Global),
3581            "de-de" => Ok(LocaleTag::DeDe),
3582            "de-at" => Ok(LocaleTag::DeAt),
3583            "de-ch" => Ok(LocaleTag::DeCh),
3584            "en-us" => Ok(LocaleTag::EnUs),
3585            "en-gb" => Ok(LocaleTag::EnGb),
3586            "en-ie" => Ok(LocaleTag::EnIe),
3587            "en-au" => Ok(LocaleTag::EnAu),
3588            "en-ca" => Ok(LocaleTag::EnCa),
3589            "" => Err(LocaleError::Unsupported),
3590            _ if is_bcp47_parseable(&raw) => Ok(LocaleTag::Other(canonical_other(&raw))),
3591            _ => Err(LocaleError::Unsupported),
3592        }
3593    }
3594
3595    /// Returns the canonical string form of the locale tag.
3596    pub fn as_str(&self) -> &str {
3597        match self {
3598            LocaleTag::Global => "global",
3599            LocaleTag::DeDe => "de-DE",
3600            LocaleTag::DeAt => "de-AT",
3601            LocaleTag::DeCh => "de-CH",
3602            LocaleTag::EnUs => "en-US",
3603            LocaleTag::EnGb => "en-GB",
3604            LocaleTag::EnIe => "en-IE",
3605            LocaleTag::EnAu => "en-AU",
3606            LocaleTag::EnCa => "en-CA",
3607            LocaleTag::Other(tag) => tag.as_str(),
3608        }
3609    }
3610}
3611
3612impl LocaleChain {
3613    /// Builds a locale chain and appends global fallback when absent.
3614    pub fn from_tags(mut tags: Vec<LocaleTag>) -> LocaleChain {
3615        ensure_global(&mut tags);
3616        LocaleChain(tags)
3617    }
3618
3619    /// Parses a comma-separated CLI locale chain.
3620    pub fn from_cli(raw: &str) -> Result<LocaleChain, LocaleError> {
3621        let tags = raw
3622            .split(',')
3623            .map(LocaleTag::parse)
3624            .collect::<Result<Vec<_>, _>>()?;
3625        Ok(LocaleChain::from_tags(tags))
3626    }
3627
3628    /// Merges policy and CLI locale preferences.
3629    pub fn merge_policy_and_cli(
3630        policy: Option<&[LocaleTag]>,
3631        cli: Option<&[LocaleTag]>,
3632    ) -> LocaleChain {
3633        Self::merge_cli_policy_rulepack_default(cli, policy, None)
3634    }
3635
3636    /// Merges CLI, policy, rulepack, and default locale preferences.
3637    pub fn merge_cli_policy_rulepack_default(
3638        cli: Option<&[LocaleTag]>,
3639        policy: Option<&[LocaleTag]>,
3640        rulepack_defaults: Option<&[LocaleTag]>,
3641    ) -> LocaleChain {
3642        let tags = cli
3643            .filter(|tags| !tags.is_empty())
3644            .or_else(|| policy.filter(|tags| !tags.is_empty()))
3645            .or_else(|| rulepack_defaults.filter(|tags| !tags.is_empty()))
3646            .map(|tags| tags.to_vec())
3647            .unwrap_or_else(|| vec![LocaleTag::Global]);
3648        LocaleChain::from_tags(tags)
3649    }
3650
3651    /// Returns true when a recognizer can run under this locale chain.
3652    pub fn intersects(&self, recognizer_locales: &[LocaleTag]) -> bool {
3653        if recognizer_locales.is_empty() {
3654            return true;
3655        }
3656        recognizer_locales.iter().any(|recognizer_locale| {
3657            *recognizer_locale == LocaleTag::Global
3658                || self.0.iter().any(|active| active == recognizer_locale)
3659        })
3660    }
3661
3662    /// Returns the locale tags in chain order.
3663    pub fn as_slice(&self) -> &[LocaleTag] {
3664        &self.0
3665    }
3666
3667    /// Returns the locale chain as canonical strings.
3668    pub fn to_strings(&self) -> Vec<String> {
3669        self.0.iter().map(ToString::to_string).collect()
3670    }
3671}
3672
3673impl From<&[LocaleTag]> for LocaleChain {
3674    fn from(tags: &[LocaleTag]) -> Self {
3675        let mut owned = tags.to_vec();
3676        ensure_global(&mut owned);
3677        LocaleChain(owned)
3678    }
3679}
3680
3681impl fmt::Display for LocaleTag {
3682    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3683        f.write_str(self.as_str())
3684    }
3685}
3686
3687/// The input document submitted for pseudonymization.
3688///
3689/// `RawDocument::Text(String)` for plain or semi-structured text (most LLM workflows).
3690/// `RawDocument::Structured(BTreeMap<String, Value>)` for JSON-shaped data where
3691/// column-aware rules apply -- `ColumnRule`s only take effect on structured input.
3692///
3693/// `Detection::span` and recognizer candidate spans use **byte** ranges, not char indices.
3694///
3695/// `RawDocument` is `#[non_exhaustive]`. Match with a wildcard arm.
3696#[derive(Debug, Clone)]
3697#[non_exhaustive]
3698pub enum RawDocument {
3699    /// Structured document values.
3700    Structured(BTreeMap<String, Value>),
3701    /// Plain text document.
3702    Text(String),
3703}
3704
3705/// The pseudonymized output from `Pipeline::redact`.
3706///
3707/// Mirrors the shape of `RawDocument`: `CleanDocument::Text(String)` or
3708/// `CleanDocument::Structured(BTreeMap<String, Value>)`. Destructure with a `let`-else
3709/// or `match`; **there is no `.text()` accessor**.
3710///
3711/// ```rust
3712/// use gaze_types::CleanDocument;
3713///
3714/// fn unwrap_text(doc: CleanDocument) -> Option<String> {
3715///     if let CleanDocument::Text(t) = doc { Some(t) } else { None }
3716/// }
3717/// ```
3718///
3719/// Contains only tokens or redacted placeholders -- no original PII values.
3720/// Send this (or its inner string) to the LLM; never send the original `RawDocument`.
3721///
3722/// `CleanDocument` is `#[non_exhaustive]`.
3723#[derive(Debug, Clone, Serialize)]
3724#[serde(untagged)]
3725#[non_exhaustive]
3726pub enum CleanDocument {
3727    /// Structured document values.
3728    Structured(BTreeMap<String, Value>),
3729    /// Plain text document.
3730    Text(String),
3731}
3732
3733/// Minimal structured value representation that avoids a serde_json dependency.
3734#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3735#[serde(untagged)]
3736#[non_exhaustive]
3737pub enum Value {
3738    /// Null value.
3739    Null,
3740    /// Boolean value.
3741    Bool(bool),
3742    /// String value.
3743    String(String),
3744    /// Signed 64-bit integer value.
3745    I64(i64),
3746    /// Array value.
3747    Array(Vec<Value>),
3748    /// Object value.
3749    Object(BTreeMap<String, Value>),
3750}
3751
3752impl Value {
3753    /// Returns the inner string for string values.
3754    pub fn as_str(&self) -> Option<&str> {
3755        match self {
3756            Self::String(value) => Some(value.as_str()),
3757            Self::Null | Self::Bool(_) | Self::I64(_) | Self::Array(_) | Self::Object(_) => None,
3758        }
3759    }
3760
3761    /// Returns a scalar string representation used for structured safety-net checks.
3762    pub fn scalar_to_safety_net_string(&self) -> Option<String> {
3763        match self {
3764            Self::String(value) if !value.is_empty() => Some(value.clone()),
3765            Self::String(_) | Self::Null | Self::Array(_) | Self::Object(_) => None,
3766            Self::Bool(value) => Some(value.to_string()),
3767            Self::I64(value) => Some(value.to_string()),
3768        }
3769    }
3770}
3771
3772impl PartialEq<&str> for Value {
3773    fn eq(&self, other: &&str) -> bool {
3774        self.as_str() == Some(*other)
3775    }
3776}
3777
3778/// Value-only dictionary bundle shared with recognizers.
3779#[derive(Debug, Clone, Default)]
3780pub struct DictionaryBundle {
3781    entries: HashMap<String, DictionaryEntry>,
3782}
3783
3784/// Request-local dictionary entry; compiled automatons live outside `gaze-types`.
3785#[derive(Debug, Clone)]
3786pub struct DictionaryEntry {
3787    terms: Vec<String>,
3788    case_sensitive: bool,
3789    source: DictionarySource,
3790    record_allowed_kinds: Option<BTreeSet<RecordMatchKind>>,
3791}
3792
3793/// Source of a dictionary entry.
3794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3795#[non_exhaustive]
3796pub enum DictionarySource {
3797    /// Dictionary supplied by request context.
3798    Cli,
3799    /// Dictionary supplied by a rulepack.
3800    Rulepack,
3801}
3802
3803/// Dictionary metadata used for diagnostics and tests.
3804#[derive(Debug, Clone, PartialEq, Eq)]
3805#[non_exhaustive]
3806pub struct DictionaryStats {
3807    /// Dictionary name.
3808    pub name: String,
3809    /// Number of configured terms.
3810    pub term_count: usize,
3811    /// Dictionary source.
3812    pub source: DictionarySource,
3813}
3814
3815impl DictionaryStats {
3816    /// Builds dictionary diagnostics metadata.
3817    pub fn new(name: impl Into<String>, term_count: usize, source: DictionarySource) -> Self {
3818        Self {
3819            name: name.into(),
3820            term_count,
3821            source,
3822        }
3823    }
3824}
3825
3826/// Dictionary declared by a rulepack.
3827#[derive(Debug, Clone, PartialEq, Eq)]
3828#[non_exhaustive]
3829pub struct RulepackDict {
3830    /// Dictionary name.
3831    pub name: String,
3832    /// Dictionary terms.
3833    pub terms: Vec<String>,
3834    /// Whether matching is case-sensitive.
3835    pub case_sensitive: bool,
3836}
3837
3838impl RulepackDict {
3839    /// Builds a rulepack dictionary declaration.
3840    pub fn new(name: impl Into<String>, terms: Vec<String>, case_sensitive: bool) -> Self {
3841        Self {
3842            name: name.into(),
3843            terms,
3844            case_sensitive,
3845        }
3846    }
3847}
3848
3849/// Error raised when constructing invalid dictionary entries.
3850#[derive(Debug, Clone, PartialEq, Eq)]
3851#[non_exhaustive]
3852pub enum DictionaryLoadError {
3853    /// Dictionary has no terms.
3854    Empty { name: String },
3855    /// ASCII-only case-insensitive matching cannot safely cover this entry.
3856    UnicodeInsensitiveUnsupported { name: String },
3857}
3858
3859impl fmt::Display for DictionaryLoadError {
3860    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3861        match self {
3862            Self::Empty { name } => write!(f, "dictionary '{name}' has no terms"),
3863            Self::UnicodeInsensitiveUnsupported { name } => write!(
3864                f,
3865                "dictionary '{name}' uses unicode terms with case-insensitive matching, unsupported in v0.4.0; use case_sensitive = true"
3866            ),
3867        }
3868    }
3869}
3870
3871impl std::error::Error for DictionaryLoadError {}
3872
3873impl DictionaryBundle {
3874    /// Builds a bundle from rulepack dictionaries.
3875    pub fn from_rulepack_terms(terms: &[RulepackDict]) -> Self {
3876        let mut entries = HashMap::with_capacity(terms.len());
3877        for dictionary in terms {
3878            let entry = DictionaryEntry::new(
3879                &dictionary.name,
3880                dictionary.terms.clone(),
3881                dictionary.case_sensitive,
3882                DictionarySource::Rulepack,
3883            )
3884            .expect("Policy validates dictionary terms before bundle construction");
3885            entries.insert(dictionary.name.clone(), entry);
3886        }
3887        Self { entries }
3888    }
3889
3890    /// Builds a bundle from pre-built dictionary entries.
3891    pub fn from_entries(entries: impl IntoIterator<Item = (String, DictionaryEntry)>) -> Self {
3892        Self {
3893            entries: entries.into_iter().collect(),
3894        }
3895    }
3896
3897    /// Merges two bundles, preferring entries from the second bundle on name conflicts.
3898    pub fn merge(a: Self, b: Self) -> Self {
3899        let mut entries = a.entries;
3900        entries.extend(b.entries);
3901        Self { entries }
3902    }
3903
3904    /// Returns a dictionary by name.
3905    pub fn get(&self, name: &str) -> Option<&DictionaryEntry> {
3906        self.entries.get(name)
3907    }
3908
3909    /// Borrows all entries for record-name corroboration within one request.
3910    pub fn iter(&self) -> impl Iterator<Item = (&str, &DictionaryEntry)> {
3911        self.entries
3912            .iter()
3913            .map(|(name, entry)| (name.as_str(), entry))
3914    }
3915
3916    /// Returns sorted dictionary stats.
3917    pub fn stats(&self) -> Vec<DictionaryStats> {
3918        let mut stats = self
3919            .entries
3920            .iter()
3921            .map(|(name, entry)| DictionaryStats {
3922                name: name.clone(),
3923                term_count: entry.terms.len(),
3924                source: entry.source,
3925            })
3926            .collect::<Vec<_>>();
3927        stats.sort_by(|a, b| a.name.cmp(&b.name));
3928        stats
3929    }
3930}
3931
3932impl DictionaryEntry {
3933    /// Creates a validated value-only dictionary entry.
3934    pub fn new(
3935        name: &str,
3936        terms: Vec<String>,
3937        case_sensitive: bool,
3938        source: DictionarySource,
3939    ) -> Result<Self, DictionaryLoadError> {
3940        if terms.is_empty() {
3941            return Err(DictionaryLoadError::Empty {
3942                name: name.to_string(),
3943            });
3944        }
3945        if !case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
3946            return Err(DictionaryLoadError::UnicodeInsensitiveUnsupported {
3947                name: name.to_string(),
3948            });
3949        }
3950        Ok(Self {
3951            terms,
3952            case_sensitive,
3953            source,
3954            record_allowed_kinds: None,
3955        })
3956    }
3957
3958    /// Carries the caller's record match policy with the request-local terms.
3959    pub fn with_record_allowed_kinds(mut self, kinds: BTreeSet<RecordMatchKind>) -> Self {
3960        self.record_allowed_kinds = Some(kinds);
3961        self
3962    }
3963
3964    /// `None` means this is an ordinary dictionary with no record policy.
3965    pub fn record_allowed_kinds(&self) -> Option<&BTreeSet<RecordMatchKind>> {
3966        self.record_allowed_kinds.as_ref()
3967    }
3968
3969    /// Returns whether matching is case-sensitive.
3970    pub fn case_sensitive(&self) -> bool {
3971        self.case_sensitive
3972    }
3973
3974    /// Returns configured dictionary terms.
3975    pub fn terms(&self) -> &[String] {
3976        &self.terms
3977    }
3978}
3979
3980#[cfg(test)]
3981mod action_tests {
3982    use super::*;
3983
3984    /// `is_protective` and `strictness_rank` are two views of one order: an
3985    /// action protects the span exactly when it ranks above `preserve`.
3986    #[test]
3987    fn protective_means_ranked_above_preserve() {
3988        for action in [
3989            Action::Tokenize,
3990            Action::Redact,
3991            Action::FormatPreserve,
3992            Action::Generalize,
3993            Action::Preserve,
3994        ] {
3995            assert_eq!(
3996                action.is_protective(),
3997                action.strictness_rank() > Action::Preserve.strictness_rank(),
3998                "{action:?}"
3999            );
4000        }
4001        assert!(!Action::Preserve.is_protective());
4002    }
4003}
4004
4005#[cfg(test)]
4006mod word_boundary_tests {
4007    use super::*;
4008
4009    const IBAN: &str = "AT611904300234573201";
4010
4011    fn extends(trailer: &str) -> bool {
4012        let text = format!("IBAN {IBAN}{trailer}");
4013        word_run_extends_identifier(&text, 5 + IBAN.len())
4014    }
4015
4016    /// Letters glued to the value are a label or a word: ASCII either case, and any Unicode
4017    /// letter, so `…3201Überweisung` and `…3201und` behave alike.
4018    #[test]
4019    fn a_run_of_letters_does_not_extend_the_identifier() {
4020        for trailer in [
4021            "",
4022            "BIC",
4023            "bic",
4024            "BIC:BKAUATWW",
4025            "Überweisung",
4026            "über",
4027            "Straße",
4028            "élan",
4029        ] {
4030            assert!(!extends(trailer), "{trailer:?}");
4031        }
4032    }
4033
4034    /// A digit, an underscore, or a non-ASCII digit anywhere in the run could be more identifier.
4035    #[test]
4036    fn a_digit_or_underscore_anywhere_in_the_run_extends_the_identifier() {
4037        for trailer in [
4038            "1",
4039            "XQ7",
4040            "BIC1",
4041            "_",
4042            "_x",
4043            "x_",
4044            "\u{661}",
4045            "BIC\u{661}",
4046            "٣",
4047        ] {
4048            assert!(extends(trailer), "{trailer:?}");
4049        }
4050    }
4051
4052    /// The run stops at the first non-word character, so what comes after a separator is not
4053    /// read, and a combining mark is not a word character to this check or to `is_inside_word`.
4054    #[test]
4055    fn the_run_stops_at_a_separator_or_a_combining_mark() {
4056        for trailer in [" 1234", ":1234", "BIC:1", "\u{301}1", "\u{301}", "-1", ".1"] {
4057            assert!(!extends(trailer), "{trailer:?}");
4058        }
4059        assert!(!is_inside_word("a\u{301}", 1));
4060    }
4061
4062    /// Malformed offsets are not an identifier continuation.
4063    #[test]
4064    fn out_of_range_or_mid_character_offsets_return_false() {
4065        assert!(!word_run_extends_identifier("AT61", 99));
4066        assert!(!word_run_extends_identifier("AT61", 4));
4067        assert!(!word_run_extends_identifier("ü1", 1));
4068    }
4069
4070    /// The two edge checks share one word predicate.
4071    #[test]
4072    fn both_edge_checks_agree_on_word_characters() {
4073        for ch in ['a', 'Z', '9', 'ü', '\u{661}', '_', '-', ' ', '\u{301}', '<'] {
4074            let text = format!("x{ch}");
4075            assert_eq!(
4076                is_inside_word(&text, 1),
4077                is_word_char(ch),
4078                "{ch:?}: is_inside_word must follow is_word_char"
4079            );
4080            let run_reads_it = word_run_extends_identifier(&format!("{ch}1"), 0);
4081            assert_eq!(
4082                run_reads_it,
4083                is_word_char(ch) || ch == '_',
4084                "{ch:?}: the run must read exactly the word characters plus underscore"
4085            );
4086        }
4087    }
4088}
4089
4090#[cfg(test)]
4091mod dictionary_tests {
4092    use super::*;
4093
4094    #[test]
4095    fn dictionary_entry_rejects_empty_terms() {
4096        let err = DictionaryEntry::new("empty", Vec::new(), true, DictionarySource::Cli)
4097            .expect_err("empty dictionaries must fail closed");
4098
4099        assert!(matches!(err, DictionaryLoadError::Empty { name } if name == "empty"));
4100    }
4101
4102    #[test]
4103    fn dictionary_entry_rejects_non_ascii_case_insensitive_terms() {
4104        let err = DictionaryEntry::new(
4105            "songs",
4106            vec!["Beyonce".to_string(), "Caf\u{00e9}".to_string()],
4107            false,
4108            DictionarySource::Cli,
4109        )
4110        .expect_err("unicode case-insensitive dictionaries must fail closed");
4111
4112        assert!(matches!(
4113            err,
4114            DictionaryLoadError::UnicodeInsensitiveUnsupported { name } if name == "songs"
4115        ));
4116    }
4117}
4118
4119#[cfg(test)]
4120mod redaction_logger_tests {
4121    use super::*;
4122
4123    struct CapturingLogger;
4124
4125    impl RedactionLogger for CapturingLogger {
4126        fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
4127            Ok(())
4128        }
4129    }
4130
4131    fn assert_send_sync<T: Send + Sync + ?Sized>() {}
4132
4133    #[test]
4134    fn fallback_reason_serializes_snake_case_and_accepts_legacy_pascal_case() {
4135        for (legacy, canonical, value) in [
4136            (
4137                "OverlapConflict",
4138                "overlap_conflict",
4139                FallbackReason::OverlapConflict,
4140            ),
4141            (
4142                "ValidatorVeto",
4143                "validator_veto",
4144                FallbackReason::ValidatorVeto,
4145            ),
4146            (
4147                "AnchorMissing",
4148                "anchor_missing",
4149                FallbackReason::AnchorMissing,
4150            ),
4151            (
4152                "ResidualSuspect",
4153                "residual_suspect",
4154                FallbackReason::ResidualSuspect,
4155            ),
4156        ] {
4157            let legacy_json = format!(r#""{legacy}""#);
4158            assert_eq!(
4159                serde_json::from_str::<FallbackReason>(&legacy_json)
4160                    .expect("legacy fallback reason"),
4161                value
4162            );
4163            assert_eq!(
4164                serde_json::to_string(&value).expect("fallback reason"),
4165                format!(r#""{canonical}""#)
4166            );
4167        }
4168    }
4169
4170    #[test]
4171    fn audit_enum_canonical_forms_round_trip() {
4172        for value in [
4173            Action::Tokenize,
4174            Action::Redact,
4175            Action::FormatPreserve,
4176            Action::Generalize,
4177            Action::Preserve,
4178        ] {
4179            assert_eq!(Action::from_canonical_str(value.as_str()), Some(value));
4180        }
4181        for value in [
4182            ConflictTier::None,
4183            ConflictTier::ClassPriority,
4184            ConflictTier::RulePriority,
4185            ConflictTier::Score,
4186            ConflictTier::SpanLength,
4187            ConflictTier::Validator,
4188            ConflictTier::SameClassContainment,
4189            ConflictTier::ValidatorVeto,
4190            ConflictTier::CollisionPolicy,
4191            ConflictTier::AnchoredContext,
4192            ConflictTier::StructuredContainment,
4193            ConflictTier::ContainmentPrecedence,
4194            ConflictTier::ProtectionOverride,
4195            ConflictTier::RecognizerId,
4196            ConflictTier::Merged,
4197            ConflictTier::Redact,
4198            ConflictTier::Resolve,
4199            ConflictTier::Fallback,
4200            ConflictTier::ManifestSweep,
4201        ] {
4202            assert_eq!(
4203                ConflictTier::from_canonical_str(value.as_str()),
4204                Some(value)
4205            );
4206        }
4207        for value in [DocumentKind::Structured, DocumentKind::Text] {
4208            assert_eq!(
4209                DocumentKind::from_canonical_str(value.as_str()),
4210                Some(value)
4211            );
4212        }
4213        for value in [
4214            FallbackReason::OverlapConflict,
4215            FallbackReason::ValidatorVeto,
4216            FallbackReason::AnchorMissing,
4217            FallbackReason::ResidualSuspect,
4218        ] {
4219            assert_eq!(
4220                FallbackReason::from_canonical_str(value.as_str()),
4221                Some(value)
4222            );
4223        }
4224    }
4225
4226    #[test]
4227    fn redaction_log_error_display_is_stable() {
4228        assert_eq!(
4229            RedactionLogError::Sqlite("write failed".to_string()).to_string(),
4230            "sqlite redaction log error: write failed"
4231        );
4232        assert_eq!(
4233            RedactionLogError::Backend("sink failed".to_string()).to_string(),
4234            "backend redaction log error: sink failed"
4235        );
4236    }
4237
4238    #[test]
4239    fn redaction_logger_trait_object_is_send_sync() {
4240        assert_send_sync::<dyn RedactionLogger>();
4241    }
4242
4243    #[test]
4244    fn local_logger_can_implement_redaction_logger() {
4245        let logger = CapturingLogger;
4246        let entry = RedactionEntry {
4247            source: "unit-test".to_string(),
4248            recognizer_id: None,
4249            recognizer_version_id: None,
4250            class: PiiClass::Email,
4251            action: Action::Tokenize,
4252            field_name: None,
4253            document_kind: DocumentKind::Text,
4254            conflict_loser: false,
4255            decided_by: ConflictTier::None,
4256            created_at: 0,
4257            session_id: None,
4258            validator_fail_reason: None,
4259            labelled_value_scan_reason: None,
4260            ambiguity_record: None,
4261            collision_family: None,
4262            collision_variant: None,
4263            fallback_triggered: None,
4264            provenance_stage: None,
4265            provenance_model_id: None,
4266            provenance_model_version: None,
4267            provenance_artifact_sha256: None,
4268            provenance_tokenizer_sha256: None,
4269            provenance_locale_resolved: None,
4270            provenance_locale_match_kind: None,
4271            provenance_canonical_class: None,
4272            provenance_native_class: None,
4273            provenance_confidence: None,
4274            provenance_merged_from: None,
4275            backend_silently_dropped: None,
4276            restore_policy: None,
4277            restore_decision: None,
4278            restore_unknown_token_count: None,
4279            restore_manifest_bypass_count: None,
4280            restore_fresh_pii_count: None,
4281            restore_phase_mask: None,
4282            restore_trap_shape_count: None,
4283        };
4284
4285        let trait_object: &dyn RedactionLogger = &logger;
4286        trait_object.log(&entry).expect("log entry");
4287    }
4288
4289    #[test]
4290    fn redaction_entry_json_shape_omits_absent_recognizer_lineage() {
4291        let entry = RedactionEntry::new(
4292            "email.global",
4293            PiiClass::Email,
4294            Action::Tokenize,
4295            None,
4296            DocumentKind::Text,
4297            false,
4298            ConflictTier::None,
4299            0,
4300            None,
4301        );
4302
4303        let rendered = serde_json::to_string(&entry).expect("serialize redaction entry");
4304
4305        assert_eq!(
4306            rendered,
4307            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}"#
4308        );
4309    }
4310
4311    #[test]
4312    fn redaction_entry_json_shape_includes_recognizer_lineage_when_present() {
4313        let entry = RedactionEntry::new(
4314            "ner/ort",
4315            PiiClass::Name,
4316            Action::Tokenize,
4317            None,
4318            DocumentKind::Text,
4319            false,
4320            ConflictTier::None,
4321            0,
4322            None,
4323        )
4324        .with_recognizer_metadata(
4325            Some("ner".to_string()),
4326            Some("ner.davlan-mbert.v1".to_string()),
4327        );
4328
4329        let value: serde_json::Value =
4330            serde_json::to_value(&entry).expect("serialize redaction entry");
4331
4332        assert_eq!(value["recognizer_id"], "ner");
4333        assert_eq!(value["recognizer_version_id"], "ner.davlan-mbert.v1");
4334    }
4335
4336    #[test]
4337    fn candidate_keeps_versioned_and_unversioned_recognizer_ids() {
4338        let unversioned = Candidate::new(
4339            0..5,
4340            PiiClass::Email,
4341            "email.global",
4342            0.9,
4343            10,
4344            None,
4345            "email",
4346            "email.global",
4347            ConflictTier::None,
4348            Vec::new(),
4349        );
4350        assert_eq!(unversioned.recognizer_id, "email.global");
4351        assert_eq!(unversioned.recognizer_version_id, None);
4352
4353        let versioned = unversioned
4354            .clone()
4355            .with_recognizer_version_id("email.global.v1");
4356        assert_eq!(versioned.recognizer_id, "email.global");
4357        assert_eq!(
4358            versioned.recognizer_version_id.as_deref(),
4359            Some("email.global.v1")
4360        );
4361    }
4362}
4363
4364#[cfg(test)]
4365mod safety_net_manifest_tests {
4366    use super::*;
4367
4368    fn span(start: usize, end: usize, class: PiiClass) -> EmittedTokenSpan {
4369        EmittedTokenSpan::new(start..end, start..end, class)
4370    }
4371
4372    fn diff(manifest: Manifest, suspect: Range<usize>, class: PiiClass) -> Option<LeakKind> {
4373        manifest.diff_against(&suspect, &class)
4374    }
4375
4376    #[test]
4377    fn exact_same_class_coverage_is_not_a_leak() {
4378        let manifest = Manifest::from_spans(vec![span(0, 8, PiiClass::Email)]);
4379
4380        assert_eq!(diff(manifest, 0..8, PiiClass::Email), None);
4381    }
4382
4383    #[test]
4384    fn uncovered_outside_all_tokens_is_uncovered() {
4385        let manifest = Manifest::from_spans(vec![span(20, 30, PiiClass::Email)]);
4386
4387        assert_eq!(
4388            diff(manifest, 0..10, PiiClass::Email),
4389            Some(LeakKind::Uncovered)
4390        );
4391    }
4392
4393    #[test]
4394    fn single_internal_gap_returns_partial_bleed() {
4395        let manifest = Manifest::from_spans(vec![
4396            span(0, 5, PiiClass::Email),
4397            span(10, 15, PiiClass::Email),
4398        ]);
4399
4400        assert_eq!(
4401            diff(manifest, 0..15, PiiClass::Email),
4402            Some(LeakKind::PartialBleed { uncovered: 5..10 })
4403        );
4404    }
4405
4406    #[test]
4407    fn multi_gap_returns_deterministic_first_uncovered_gap() {
4408        let manifest = Manifest::from_spans(vec![
4409            span(0, 3, PiiClass::Email),
4410            span(5, 7, PiiClass::Email),
4411            span(9, 12, PiiClass::Email),
4412        ]);
4413
4414        // The first-gap-only rule is intentional for v0.6.1; full gap
4415        // enumeration is deferred until the report format can carry it.
4416        assert_eq!(
4417            diff(manifest, 0..12, PiiClass::Email),
4418            Some(LeakKind::PartialBleed { uncovered: 3..5 })
4419        );
4420    }
4421
4422    #[test]
4423    fn multi_class_overlap_reports_first_mismatch_deterministically() {
4424        let manifest = Manifest::from_spans(vec![
4425            span(0, 4, PiiClass::Name),
4426            span(4, 8, PiiClass::Location),
4427        ]);
4428
4429        assert_eq!(
4430            diff(manifest, 0..8, PiiClass::Email),
4431            Some(LeakKind::ClassMismatch {
4432                pipeline_class: PiiClass::Name,
4433                safety_net_class: PiiClass::Email,
4434            })
4435        );
4436    }
4437
4438    #[test]
4439    fn adjacent_same_class_tokens_cover_continuously() {
4440        let manifest = Manifest::from_spans(vec![
4441            span(0, 5, PiiClass::Email),
4442            span(5, 10, PiiClass::Email),
4443        ]);
4444
4445        assert_eq!(diff(manifest, 0..10, PiiClass::Email), None);
4446    }
4447
4448    #[test]
4449    fn partial_bleed_at_start_end_and_middle() {
4450        let manifest = Manifest::from_spans(vec![span(3, 8, PiiClass::Email)]);
4451
4452        assert_eq!(
4453            diff(manifest.clone(), 0..8, PiiClass::Email),
4454            Some(LeakKind::PartialBleed { uncovered: 0..3 })
4455        );
4456        assert_eq!(
4457            diff(manifest.clone(), 3..10, PiiClass::Email),
4458            Some(LeakKind::PartialBleed { uncovered: 8..10 })
4459        );
4460
4461        let with_gap = Manifest::from_spans(vec![
4462            span(0, 3, PiiClass::Email),
4463            span(6, 10, PiiClass::Email),
4464        ]);
4465        assert_eq!(
4466            diff(with_gap, 0..10, PiiClass::Email),
4467            Some(LeakKind::PartialBleed { uncovered: 3..6 })
4468        );
4469    }
4470
4471    #[test]
4472    fn byte_indices_are_not_character_indices() {
4473        let text = "ID: 😀 <Email_1>";
4474        let token_start = text.find("<Email_1>").expect("token start");
4475        assert_eq!(token_start, 9, "emoji is four bytes, not one char");
4476        let manifest = Manifest::from_spans(vec![span(token_start, text.len(), PiiClass::Email)]);
4477
4478        assert_eq!(
4479            diff(manifest, token_start..text.len(), PiiClass::Email),
4480            None
4481        );
4482    }
4483
4484    #[test]
4485    fn empty_suspect_range_is_not_a_leak() {
4486        let manifest = Manifest::default();
4487
4488        assert_eq!(diff(manifest, 3..3, PiiClass::Email), None);
4489    }
4490
4491    #[test]
4492    fn safety_net_error_display_is_variant_specific_and_bytes_free() {
4493        let cases = [
4494            SafetyNetError::Unavailable {
4495                reason: "not configured".to_string(),
4496            }
4497            .to_string(),
4498            SafetyNetError::WeightsMissing {
4499                path: "/models/opf".to_string(),
4500            }
4501            .to_string(),
4502            SafetyNetError::ModelUnavailable {
4503                reason: "load failed".to_string(),
4504            }
4505            .to_string(),
4506            SafetyNetError::ModelIntegrityMismatch {
4507                expected: "e3b0c44298fc1c149afbf4c8996fb924".to_string(),
4508                actual: "4e07408562bedb8b60ce05c1decfe3ad".to_string(),
4509            }
4510            .to_string(),
4511            SafetyNetError::InputTooLarge {
4512                limit: 1024,
4513                actual: 2048,
4514            }
4515            .to_string(),
4516            SafetyNetError::Runtime {
4517                message: "timeout".to_string(),
4518            }
4519            .to_string(),
4520            SafetyNetError::InvalidOutput {
4521                message: "bad json".to_string(),
4522            }
4523            .to_string(),
4524        ];
4525
4526        for rendered in cases {
4527            assert!(!rendered.contains("alice@example.invalid"));
4528        }
4529    }
4530}
4531
4532/// Shared recognizer contract for locale-aware PII candidates.
4533pub trait Recognizer: Send + Sync {
4534    /// Stable recognizer identifier.
4535    fn id(&self) -> &str;
4536    /// PII class supported by this recognizer.
4537    fn supported_class(&self) -> &PiiClass;
4538    /// Every class this recognizer can emit. Multi-label recognizers override this.
4539    fn possible_classes(&self) -> Vec<PiiClass> {
4540        vec![self.supported_class().clone()]
4541    }
4542    /// Detects PII candidates in the supplied input and context.
4543    fn detect(
4544        &self,
4545        input: &str,
4546        ctx: &DetectContext<'_>,
4547    ) -> std::result::Result<Vec<Candidate>, DetectError>;
4548    /// Registry-only detection view that may include marked guard vetoes for audit.
4549    /// Direct callers of [`Self::detect`] receive accepted candidates only.
4550    fn detect_for_registry(
4551        &self,
4552        input: &str,
4553        ctx: &DetectContext<'_>,
4554    ) -> std::result::Result<Vec<Candidate>, DetectError> {
4555        self.detect(input, ctx)
4556    }
4557    /// Token family used for candidate token emission.
4558    fn token_family(&self) -> &str;
4559    /// Optional validator kind used by pre-resolver validator-veto.
4560    fn validator_kind(&self) -> Option<ValidatorKind> {
4561        None
4562    }
4563    /// What validator veto does when [`Self::validator_kind`] fails. Defaults to
4564    /// [`ValidatorOnFail::Veto`]; validator veto honours [`ValidatorOnFail::Record`] only for
4565    /// kinds where [`ValidatorKind::allows_recorded_failure`] holds.
4566    fn validator_on_fail(&self) -> ValidatorOnFail {
4567        ValidatorOnFail::Veto
4568    }
4569    /// Locales where this recognizer is active.
4570    fn locales(&self) -> &[LocaleTag] {
4571        &[LocaleTag::Global]
4572    }
4573    /// Interpretation of [`Self::locales`] for request eligibility.
4574    fn locale_basis(&self) -> LocaleBasis {
4575        LocaleBasis::Document
4576    }
4577    /// Whether [`Self::detect`] returns the same candidates for the same input whatever
4578    /// `ctx.locale_chain` holds.
4579    ///
4580    /// A document-basis recognizer is offered every locale-chain step it is active for. When
4581    /// this returns `true`, the registry calls `detect` once per document and reuses the result
4582    /// at later steps; span claiming across steps is unchanged. Return `true` only when the
4583    /// output ignores the step locale; the default `false` keeps one call per step.
4584    fn detect_is_locale_invariant(&self) -> bool {
4585        false
4586    }
4587
4588    /// Run after ordinary recognizers when detection needs their raw candidates.
4589    fn requires_prior_candidates(&self) -> bool {
4590        false
4591    }
4592
4593    /// What this recognizer's candidates rest on. The registry stamps it on every candidate
4594    /// the recognizer emits, overwriting [`Candidate::evidence`]. Defaults to
4595    /// [`EvidenceKind::Learned`], which the repeat-value sweep never propagates; see
4596    /// [`EvidenceKind`].
4597    fn evidence(&self) -> EvidenceKind {
4598        EvidenceKind::Learned
4599    }
4600}
4601
4602/// Caller-visible recognizer detection failure.
4603#[derive(Debug, Clone, PartialEq, Eq, Error)]
4604#[non_exhaustive]
4605pub enum DetectError {
4606    /// A recognizer backend failed while scanning the input.
4607    #[error("recognizer {recognizer_id} backend failed: {message}")]
4608    Backend {
4609        /// Stable recognizer identifier.
4610        recognizer_id: String,
4611        /// Sanitized backend error message.
4612        message: String,
4613    },
4614}
4615
4616impl DetectError {
4617    /// Build a backend failure without requiring recognizer crates in `gaze-types`.
4618    pub fn backend(recognizer_id: impl Into<String>, source: impl Into<String>) -> Self {
4619        Self::Backend {
4620            recognizer_id: recognizer_id.into(),
4621            message: source.into(),
4622        }
4623    }
4624}
4625
4626/// Candidate PII span emitted by a recognizer before final conflict resolution.
4627#[derive(Debug, Clone, PartialEq)]
4628#[non_exhaustive]
4629pub struct Candidate {
4630    /// Byte span in the original input.
4631    pub span: Range<usize>,
4632    /// PII class assigned to the span.
4633    pub class: PiiClass,
4634    /// Recognizer identifier.
4635    pub recognizer_id: String,
4636    /// Optional versioned recognizer identifier for audit lineage.
4637    pub recognizer_version_id: Option<String>,
4638    /// Recognizer confidence score.
4639    pub score: f32,
4640    /// Rule or recognizer priority.
4641    pub priority: i32,
4642    /// Optional canonical representation for validation/merge logic.
4643    pub canonical_form: Option<String>,
4644    /// Token family used for output token shape.
4645    pub token_family: String,
4646    /// Candidate source label.
4647    pub source: String,
4648    /// Conflict tier that decided this candidate.
4649    pub decided_by: ConflictTier,
4650    /// Sources merged into this candidate.
4651    pub merged_sources: Vec<String>,
4652    /// Original recognizer IDs contributing to this candidate's provenance.
4653    pub source_recognizer_ids: Vec<String>,
4654    /// What the candidate rests on. [`Candidate::new`] starts at [`EvidenceKind::Learned`];
4655    /// the registry overwrites it with the emitting recognizer's [`Recognizer::evidence`].
4656    pub evidence: EvidenceKind,
4657    /// Set when validator veto kept the candidate although its validator failed
4658    /// ([`ValidatorOnFail::Record`]); written on the winner's audit row.
4659    pub validator_fail_reason: Option<ValidatorFailReason>,
4660    /// Closed reason for a labelled value boundary or length limit.
4661    pub labelled_value_scan_reason: Option<LabelledValueScanReason>,
4662    /// End of the original labelled-value capture before extension, for cross-class trimming.
4663    pub labelled_value_capture_end: Option<usize>,
4664    /// The recognizer's match guard vetoed the original capture.
4665    pub regex_guard_rejected: bool,
4666}
4667
4668impl Candidate {
4669    /// Builds a recognizer candidate.
4670    #[allow(clippy::too_many_arguments)]
4671    pub fn new(
4672        span: Range<usize>,
4673        class: PiiClass,
4674        recognizer_id: impl Into<String>,
4675        score: f32,
4676        priority: i32,
4677        canonical_form: Option<String>,
4678        token_family: impl Into<String>,
4679        source: impl Into<String>,
4680        decided_by: ConflictTier,
4681        merged_sources: Vec<String>,
4682    ) -> Self {
4683        let recognizer_id = recognizer_id.into();
4684        Self {
4685            span,
4686            class,
4687            source_recognizer_ids: vec![recognizer_id.clone()],
4688            recognizer_id,
4689            recognizer_version_id: None,
4690            score,
4691            priority,
4692            canonical_form,
4693            token_family: token_family.into(),
4694            source: source.into(),
4695            decided_by,
4696            merged_sources,
4697            evidence: EvidenceKind::Learned,
4698            validator_fail_reason: None,
4699            labelled_value_scan_reason: None,
4700            labelled_value_capture_end: None,
4701            regex_guard_rejected: false,
4702        }
4703    }
4704
4705    /// Whether a validator passed on this candidate: it carries a canonical form and no recorded
4706    /// failure. A canonical form alone proves nothing: an IBAN or card kept by
4707    /// [`ValidatorOnFail::Record`] may carry its normalizer's canonical form. The resolver's
4708    /// evidence tiers and the repeat-value sweep both ask this.
4709    pub fn checksum_validated(&self) -> bool {
4710        self.canonical_form.is_some() && self.validator_fail_reason.is_none()
4711    }
4712
4713    /// Returns this candidate with its evidence kind set.
4714    pub fn with_evidence(mut self, evidence: EvidenceKind) -> Self {
4715        self.evidence = evidence;
4716        self
4717    }
4718
4719    /// Returns this candidate with a translated span.
4720    pub fn with_span(mut self, span: Range<usize>) -> Self {
4721        self.span = span;
4722        self
4723    }
4724
4725    /// Returns this candidate with versioned recognizer lineage attached.
4726    pub fn with_recognizer_version_id(mut self, recognizer_version_id: impl Into<String>) -> Self {
4727        self.recognizer_version_id = Some(recognizer_version_id.into());
4728        self
4729    }
4730}
4731
4732/// Context supplied to recognizers during detection.
4733#[non_exhaustive]
4734pub struct DetectContext<'a> {
4735    /// Active locale chain.
4736    pub locale_chain: &'a [LocaleTag],
4737    /// Active dictionary bundle.
4738    pub dictionaries: &'a DictionaryBundle,
4739    /// Reserved field-aware matching slot; intentionally unit in v0.5 Phase B.
4740    pub fields: &'a (),
4741    /// Whether a recognizer degraded due to unavailable optional capability.
4742    pub degraded: Cell<bool>,
4743    /// For each byte of the detection input, the byte range of the caller's source text it
4744    /// came from, when the input is a normalized view of that text. A recognizer may read it to
4745    /// see breaks normalization hid (a dropped ZERO WIDTH JOINER, fullwidth digits next to
4746    /// ASCII); `card.structural` does. `None` when the input is the source text itself.
4747    pub source_spans: Option<&'a [(usize, usize)]>,
4748    /// Raw candidates found by ordinary recognizers, available only to a recognizer that
4749    /// requests the post-floor pass. They have not passed validator veto or conflict resolution.
4750    pub prior_candidates: Option<&'a [Candidate]>,
4751}
4752
4753impl<'a> DetectContext<'a> {
4754    /// Builds detection context for a recognizer pass.
4755    pub fn new(locale_chain: &'a [LocaleTag], dictionaries: &'a DictionaryBundle) -> Self {
4756        Self {
4757            locale_chain,
4758            dictionaries,
4759            fields: &(),
4760            degraded: Cell::new(false),
4761            source_spans: None,
4762            prior_candidates: None,
4763        }
4764    }
4765
4766    /// Records the source span of every input byte; see [`DetectContext::source_spans`].
4767    pub fn with_source_spans(mut self, source_spans: &'a [(usize, usize)]) -> Self {
4768        self.source_spans = Some(source_spans);
4769        self
4770    }
4771
4772    pub fn with_prior_candidates(mut self, candidates: &'a [Candidate]) -> Self {
4773        self.prior_candidates = Some(candidates);
4774        self
4775    }
4776}
4777
4778fn ensure_global(tags: &mut Vec<LocaleTag>) {
4779    if !tags.contains(&LocaleTag::Global) {
4780        tags.push(LocaleTag::Global);
4781    }
4782}
4783
4784fn is_bcp47_parseable(raw: &str) -> bool {
4785    let mut parts = raw.split('-');
4786    let Some(language) = parts.next() else {
4787        return false;
4788    };
4789    if !(2..=8).contains(&language.len()) || !language.chars().all(|ch| ch.is_ascii_alphabetic()) {
4790        return false;
4791    }
4792    parts.all(|part| {
4793        (2..=8).contains(&part.len()) && part.chars().all(|ch| ch.is_ascii_alphanumeric())
4794    })
4795}
4796
4797fn canonical_other(raw: &str) -> String {
4798    let mut parts = raw.split('-');
4799    let language = parts.next().unwrap_or_default().to_ascii_lowercase();
4800    let rest = parts.map(|part| {
4801        if part.len() == 2 && part.chars().all(|ch| ch.is_ascii_alphabetic()) {
4802            part.to_ascii_uppercase()
4803        } else {
4804            part.to_ascii_lowercase()
4805        }
4806    });
4807    std::iter::once(language)
4808        .chain(rest)
4809        .collect::<Vec<_>>()
4810        .join("-")
4811}
4812
4813#[cfg(test)]
4814mod emitted_token_origin_tests {
4815    use super::*;
4816
4817    /// The exact JSON a whole-value span produced before `origin` existed.
4818    /// It is a byte-for-byte snapshot on purpose: this field is persisted in
4819    /// session snapshots and document-extension bundles, so a stored whole span
4820    /// must serialize identically after the field was added, or every existing
4821    /// file on disk changes meaning.
4822    const LEGACY_WHOLE_JSON: &str =
4823        r#"{"clean_span":{"start":0,"end":8},"raw_span":{"start":0,"end":12},"class":"Email"}"#;
4824
4825    #[test]
4826    fn whole_spans_serialize_byte_identically_to_the_pre_origin_snapshot() {
4827        let span = EmittedTokenSpan::new(0..8, 0..12, PiiClass::Email);
4828        let json = serde_json::to_string(&span).unwrap();
4829        assert_eq!(
4830            json, LEGACY_WHOLE_JSON,
4831            "adding `origin` changed the on-disk form of an existing whole span"
4832        );
4833        assert!(!json.contains("origin"));
4834    }
4835
4836    #[test]
4837    fn json_written_before_the_field_existed_reads_back_as_whole() {
4838        let span: EmittedTokenSpan = serde_json::from_str(LEGACY_WHOLE_JSON).unwrap();
4839        assert_eq!(span.origin, EmittedTokenOrigin::Whole);
4840        assert!(span.origin.is_whole());
4841        assert_eq!(span, EmittedTokenSpan::new(0..8, 0..12, PiiClass::Email));
4842    }
4843
4844    #[test]
4845    fn fragments_serialize_explicitly_and_round_trip() {
4846        let fragment = EmittedTokenSpan::residual_fragment(12..18, 27..33, PiiClass::Email);
4847        let json = serde_json::to_string(&fragment).unwrap();
4848        assert!(
4849            json.contains(r#""origin":"residual_fragment""#),
4850            "a fragment must say so on the wire: {json}"
4851        );
4852        assert_eq!(
4853            serde_json::from_str::<EmittedTokenSpan>(&json).unwrap(),
4854            fragment
4855        );
4856        assert!(fragment.origin.is_residual_fragment());
4857        assert!(!fragment.origin.is_whole());
4858    }
4859
4860    #[test]
4861    fn a_fragment_is_never_equal_to_the_same_geometry_as_a_whole() {
4862        // Consumers branch on this, so the distinction has to survive equality
4863        // and not collapse into "same span, same class".
4864        let whole = EmittedTokenSpan::new(12..18, 27..33, PiiClass::Email);
4865        let fragment = EmittedTokenSpan::residual_fragment(12..18, 27..33, PiiClass::Email);
4866        assert_ne!(whole, fragment);
4867    }
4868}
4869
4870#[cfg(test)]
4871mod name_edge_tests {
4872    use super::{expand_to_word_edges, extend_over_name_joiners};
4873
4874    fn grown(
4875        text: &str,
4876        part: &str,
4877        grow: fn(&str, std::ops::Range<usize>) -> std::ops::Range<usize>,
4878    ) -> String {
4879        let start = text.find(part).expect("part in text");
4880        text[grow(text, start..start + part.len())].to_string()
4881    }
4882
4883    #[test]
4884    fn word_edges_take_the_whole_word_and_nothing_past_it() {
4885        assert_eq!(
4886            grown(
4887                "ping jorunn vasquez-ellery",
4888                "jorunn vas",
4889                expand_to_word_edges
4890            ),
4891            "jorunn vasquez"
4892        );
4893        assert_eq!(grown("x JORUNN y", "ORUNN", expand_to_word_edges), "JORUNN");
4894        assert_eq!(
4895            grown("Müllerstraße 5", "llerstr", expand_to_word_edges),
4896            "Müllerstraße"
4897        );
4898        assert_eq!(grown("a b", "a", expand_to_word_edges), "a");
4899        assert_eq!(expand_to_word_edges("abc", 5..9), 5..9);
4900    }
4901
4902    #[test]
4903    fn joiners_take_glued_name_parts_on_either_side() {
4904        let grow = extend_over_name_joiners;
4905        assert_eq!(
4906            grown("ping vasquez-ellery now", "vasquez", grow),
4907            "vasquez-ellery"
4908        );
4909        assert_eq!(
4910            grown("ping ellery-vasquez now", "vasquez", grow),
4911            "ellery-vasquez"
4912        );
4913        assert_eq!(
4914            grown("Anna-Lena Kim-Park", "Lena Kim", grow),
4915            "Anna-Lena Kim-Park"
4916        );
4917        assert_eq!(grown("O'Brien said", "Brien", grow), "O'Brien");
4918        assert_eq!(
4919            grown("d\u{2019}Artagnan", "Artagnan", grow),
4920            "d\u{2019}Artagnan"
4921        );
4922    }
4923
4924    #[test]
4925    fn joiners_refuse_possessives_digits_and_bare_joiners() {
4926        let grow = extend_over_name_joiners;
4927        assert_eq!(grown("Vasquez's desk", "Vasquez", grow), "Vasquez");
4928        assert_eq!(grown("Vasquez\u{2019}s desk", "Vasquez", grow), "Vasquez");
4929        assert_eq!(grown("Vasquez-2024", "Vasquez", grow), "Vasquez");
4930        assert_eq!(grown("Vasquez-ab12", "Vasquez", grow), "Vasquez");
4931        assert_eq!(grown("Vasquez - Ellery", "Vasquez", grow), "Vasquez");
4932        assert_eq!(grown("Vasquez-", "Vasquez", grow), "Vasquez");
4933        assert_eq!(grown("1-Vasquez", "Vasquez", grow), "Vasquez");
4934    }
4935}