schema_version = "0.1.0"
rulepack_id = "gaze-core"
rulepack_version = "0.5.1"
default_locales = ["global"]

[locale.email_headers]
names = ["From", "To", "Cc", "Bcc", "Reply-To", "Sender"]

[[recognizers]]
id = "email.global"
safety_tier = "safe_default"
class = "Email"
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
# The structural TLD branch excludes reserved restore/fake-token TLDs (`invalid`,
# `test`); the explicit branches above keep example.invalid/test.local behavior.
pattern = '''(?i)(?-u:\b)([a-z0-9_][a-z0-9._%+\-]*@(?:(?:[a-z0-9\-]+\.)*example\.invalid|test\.local|(?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?\.)+(?:[a-z]{2,3}|[a-su-z][a-z]{3}|t[a-df-z][a-z]{2}|te[a-rt-z][a-z]|tes[a-su-z]|[a-z]{5,6}|[a-hj-z][a-z]{6}|i[a-mo-z][a-z]{5}|in[a-uw-z][a-z]{4}|inv[b-z][a-z]{3}|inva[a-km-z][a-z]{2}|inval[a-hj-z][a-z]|invali[a-ce-z]|[a-z]{8,})))(?-u:\b)'''
capture_groups = [1]

[recognizers.context]
exclusions = ["test.local"]

# Reserved — not yet applied; see v0.4.1 roadmap.
# hotwords = ["email", "e-mail", "mail"]
# window = 12
# boost = 0.10

[recognizers.validator]
kind = "email_rfc"

[recognizers.normalizer]
kind = "email_canonical"

[recognizers.scoring]
base = 0.70
priority = 90

[recognizers.token]
# Reserved — not yet applied; see v0.4.1 roadmap.
# family = "email.counter"
# format = "Email_{n}"

[recognizers.source]
origin = "ported"
from = "presidio"
license = "Apache-2.0"

[[recognizers]]
id = "email.header.name"
safety_tier = "safe_default"
class = "Name"
cooperates_with = ["email.header.name.paren", "name.forward_marker", "name.agent_recipient", "name.auto_footer"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
pattern_template = '''(?m)^(?:{locale.email_headers}):\s*(?:"([^"]+)"|([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}))\s+<[^>]+>'''
capture_groups = [1, 2]

[recognizers.scoring]
base = 0.85
priority = 100

[[recognizers]]
id = "email.header.name.paren"
safety_tier = "safe_default"
class = "Name"
cooperates_with = ["email.header.name", "name.forward_marker", "name.agent_recipient", "name.auto_footer"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
pattern_template = '''(?:(?:{locale.email_headers}):|,)\s*\S+@\S+\s*\(([^)]+)\)'''
capture_groups = [1]

[recognizers.scoring]
base = 0.85
priority = 100

[recognizers.token]
family = "name.counter"

[[recognizers]]
id = "name.forward_marker"
safety_tier = "safe_default"
class = "Name"
cooperates_with = ["email.header.name", "email.header.name.paren", "name.agent_recipient", "name.auto_footer"]
enabled = true
locales = ["de-DE", "de-AT", "de-CH", "en-US", "en-GB", "en-IE", "en-AU", "en-CA"]

[recognizers.match]
kind = "anchored_match"
cues_bucket = "forward_markers"
boundary = "punctuation"
right_window_chars = 64
name_shape = "person_name"
cue_position = "before"

[recognizers.scoring]
base = 0.88
priority = 110

[recognizers.token]
family = "name.counter"

[[recognizers]]
id = "name.agent_recipient"
safety_tier = "safe_default"
class = "Name"
cooperates_with = ["email.header.name", "email.header.name.paren", "name.forward_marker", "name.auto_footer"]
enabled = true
locales = ["de-DE", "de-AT", "de-CH", "en-US", "en-GB", "en-IE", "en-AU", "en-CA"]

[recognizers.match]
kind = "anchored_match"
cues_bucket = "agent_recipient_cues"
boundary = "punctuation"
right_window_chars = 48
name_shape = "person_name"
cue_position = "before"

[recognizers.scoring]
base = 0.88
priority = 110

[recognizers.token]
family = "name.counter"

[[recognizers]]
id = "name.auto_footer"
safety_tier = "safe_default"
class = "Name"
cooperates_with = ["email.header.name", "email.header.name.paren", "name.forward_marker", "name.agent_recipient"]
enabled = true
locales = ["de-DE", "de-AT", "de-CH", "en-US", "en-GB", "en-IE", "en-AU", "en-CA"]

[recognizers.match]
kind = "anchored_match"
cues_bucket = "footer_cues"
boundary = "whitespace"
right_window_chars = 64
name_shape = "person_name"
cue_position = "before"

[recognizers.scoring]
base = 0.88
priority = 110

[recognizers.token]
family = "name.counter"

[[recognizers]]
id = "phone.structural"
safety_tier = "safe_default"
class = "custom:phone"
cooperates_with = ["phone.e164.spaced", "phone.national.de", "phone.national.us"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
# Mirrors the national phone consuming boundary so phone.structural cannot
# re-open identifier-attached E.164 matches rejected by locale recognizers;
# cross-recognizer asymmetry caught in PR #126 review scratchpad 795.
pattern = '''(?:^|[^[:alnum:]_+])(\+\d{6,15})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "e164_phone"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[recognizers.collision]
family = "phone-or-imei"
variant = "phone"
precedence = 10

[[recognizers]]
id = "phone.e164.spaced"
safety_tier = "safe_default"
class = "custom:phone"
cooperates_with = ["phone.structural", "phone.national.de", "phone.national.us"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
# Broad spaced international candidate scanner for country codes that do not
# already have regional validators. +1 and spaced +49 remain with the US/DE
# national recognizers because their safe fixtures are region-validator backed.
pattern = '''(?:^|[^[:alnum:]_+])(\+(?:[235-9]\d{0,2}|4(?:[0-8]\d?|9\d))[\s.\-/]?(?:\d[\s.\-/]?){6,14}\d)\b'''
capture_groups = [1]

[recognizers.validator]
kind = "e164_phone"

[recognizers.scoring]
base = 0.70
priority = 79

[recognizers.token]

[recognizers.collision]
family = "phone-or-imei"
variant = "phone"
precedence = 10

[[recognizers]]
id = "phone.national.de"
safety_tier = "locale_gated"
class = "custom:phone"
cooperates_with = ["phone.structural", "phone.e164.spaced", "phone.national.us"]
enabled = true
locales = ["de-DE", "de-AT", "de-CH"]

[recognizers.match]
kind = "regex"
# Source: BNetzA Vorwahlverzeichnis (https://www.bundesnetzagentur.de/DE/Fachthemen/Telekommunikation/Nummerierung/ONRufnr/start.html), as-of 2026-04-29.
# BNetzA lists thousands of 2- to 5-digit Ortsnetzkennzahlen; this broad
# candidate shape delegates final validity to the E164PhoneNational(De)
# parser-backed validator while preserving identifier-attached rejection.
# Broad candidate scanning stays tenant-agnostic. Leading no-capture branches
# consume country-neutral 22-character IBAN grouping and short 4+5 numeric ID
# shapes before phone parsing sees phone-like substrings.
pattern = '''(?x)\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){4}\s?[A-Z0-9]{2}\b|(?:^|[^[:alnum:]_+])0[1-9]\d{2}[\s./-]*\d{5}\b|(?:^|[^[:alnum:]_+])((?:\+49[\s.-]*|0)[1-9]\d{1,4}[\s./-]*\d(?:[\s./-]*\d){4,10})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "e164_phone_national_de"

[recognizers.scoring]
base = 0.82
priority = 85

[recognizers.token]

[recognizers.collision]
family = "phone-or-imei"
variant = "phone"
precedence = 10

[[recognizers]]
id = "phone.national.us"
safety_tier = "locale_gated"
class = "custom:phone"
cooperates_with = ["phone.structural", "phone.e164.spaced", "phone.national.de"]
enabled = true
locales = ["en-US"]

[recognizers.match]
kind = "regex"
# Mirrors phone.national.de's consuming boundary so identifier-attached
# numbers reject consistently; see audit scratchpad 778.
pattern = '''(?x)(?:^|[^[:alnum:]_+])((?:\+?1[\s.-]+555[\s.-]*01\d{2}|\+?1[\s.-]+(?:\([2-9]\d{2}\)|[2-9]\d{2})[\s.-]*[2-9]\d{2}[\s.-]*\d{4}|\([2-9]\d{2}\)[\s.-]*[2-9]\d{2}[\s.-]*\d{4}|[2-9]\d{2}[\s.-]+[2-9]\d{2}[\s.-]+\d{4}|555[\s.-]*01\d{2}))\b'''
capture_groups = [1]

[recognizers.validator]
kind = "e164_phone_national_us"

[recognizers.scoring]
base = 0.82
priority = 85

[recognizers.token]

[recognizers.collision]
family = "phone-or-imei"
variant = "phone"
precedence = 10

[[recognizers]]
id = "iban.structural"
safety_tier = "safe_default"
class = "custom:iban"
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
# Allows internal whitespace; iban_canonical normalizer strips before mod-97 validation.
pattern = '''\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7} ?[A-Z0-9]{1,4}\b'''

[recognizers.validator]
kind = "iban_mod97"

[recognizers.normalizer]
kind = "iban_canonical"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[recognizers.collision]
family = "payment-card-or-iban"
variant = "iban"
precedence = 10
mandatory_anchor = "iban"

[[recognizers]]
id = "card.structural"
safety_tier = "safe_default"
class = "custom:credit_card"
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
pattern = '''\b\d(?:[\s-]?\d){12,18}\b'''

[recognizers.validator]
kind = "luhn"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[recognizers.collision]
family = "payment-card-or-iban"
variant = "pan"
precedence = 20

[[recognizers]]
id = "ip.v4"
safety_tier = "safe_default"
class = "custom:ip_address"
cooperates_with = ["ip.v6"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
pattern = '''\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b'''

[recognizers.validator]
kind = "ipv4_parse"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[[recognizers]]
id = "ip.v6"
safety_tier = "safe_default"
class = "custom:ip_address"
cooperates_with = ["ip.v4"]
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
# IPv4-embedded alternatives derive from RFC 4291 Section 2.2 form 3
# (`x:x:x:x:x:x:d.d.d.d`), with the audit trail captured in scratchpad 778.
pattern = '''(?ix)
(?:^|[^0-9a-f:.])
(
  (?:
    (?:[0-9a-f]{1,4}:){6}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |::(?:[0-9a-f]{1,4}:){0,5}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |(?:[0-9a-f]{1,4}:){1}:(?:[0-9a-f]{1,4}:){0,4}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |(?:[0-9a-f]{1,4}:){2}:(?:[0-9a-f]{1,4}:){0,3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |(?:[0-9a-f]{1,4}:){3}:(?:[0-9a-f]{1,4}:){0,2}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |(?:[0-9a-f]{1,4}:){4}:(?:[0-9a-f]{1,4}:){0,1}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
    |(?:[0-9a-f]{1,4}:){5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
  )
  |
  (?:
    (?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}
    |(?:[0-9a-f]{1,4}:){1,7}:
    |(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}
    |(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}
    |(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}
    |(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}
    |(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}
    |[0-9a-f]{1,4}:(?:(?::[0-9a-f]{1,4}){1,6})
    |::(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4}
    |::
  )
)
(?:$|[^0-9a-f:.]|\.(?:$|[^0-9]))
'''
capture_groups = [1]

[recognizers.validator]
kind = "ipv6_parse"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[[recognizers]]
id = "eth.address"
safety_tier = "safe_default"
class = "custom:eth_address"
enabled = true
locales = ["global"]

[recognizers.match]
kind = "regex"
pattern = '''\b0x[0-9a-fA-F]{40}\b'''

[recognizers.validator]
kind = "eth_eip55"

[recognizers.scoring]
base = 0.70
priority = 80

[recognizers.token]

[[recognizers]]
id = "aadhaar.in"
safety_tier = "safe_default"
class = "custom:aadhaar"
enabled = true
locales = ["en-IN", "hi-IN"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:aadhaar|uid|unique identification|आधार)(?:\s*(?:number|no\.?|id))?\s*[:#-]?\s*(\d{4}[ -]?\d{4}[ -]?\d{4})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "aadhaar_verhoeff"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "nir.fr"
safety_tier = "safe_default"
class = "custom:nir"
enabled = true
locales = ["fr-FR"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:n[°º]?\s*ss|nir|s[ée]curit[ée]\s+sociale|num[ée]ro\s+de\s+s[ée]curit[ée]\s+sociale)\s*[:#-]?\s*([123478]\d(?:[ -]?\d){13})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "fr_nir_mod97"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "steuer_id.de"
safety_tier = "safe_default"
class = "custom:steuer_id"
enabled = true
locales = ["de-DE", "de-AT", "de-CH"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:steuer-id|steuerid|idnr\.?|steuerliche\s+identifikationsnummer|identifikationsnummer)\s*[:#-]?\s*(\d{2}[ -]?\d{3}[ -]?\d{3}[ -]?\d{3})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "de_steuer_id_mod1110"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "vat.de"
safety_tier = "locale_gated"
class = "custom:vat_id"
cooperates_with = ["vat.es"]
enabled = true
locales = ["de-DE", "de-AT", "de-CH"]

[recognizers.match]
kind = "regex"
# Keep DE USt-IdNr cue-anchored so the short `DE` + 9 digit shape cannot
# compete with unrelated identifiers, but do not checksum-gate reported leaks.
pattern = '''(?i)\b(?:ust[\s.-]*(?:idnr\.?|id)|umsatzsteuer[\s-]*id|mwst[\s-]*nr\.?)\s*[:#-]?\s*(DE\d{9})\b'''
capture_groups = [1]

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "vat.es"
safety_tier = "locale_gated"
class = "custom:vat_id"
cooperates_with = ["vat.de"]
enabled = true
locales = ["es-ES"]

[recognizers.match]
kind = "regex"
# ES VAT/CIF/NIF checks vary by entity type. Until that checksum is implemented
# end-to-end, keep ES format-gated instead of risking false checksum rejects.
pattern = '''(?i)\b(?:nif[\s-]*iva|cif|nif|iva|vat(?:[\s-]*id)?)\s*[:#-]?\s*(ES[A-Z0-9]\d{7}[A-Z0-9])\b'''
capture_groups = [1]

[recognizers.scoring]
base = 0.84
priority = 84

[recognizers.token]

[[recognizers]]
id = "bsn.nl"
safety_tier = "safe_default"
class = "custom:bsn"
enabled = true
locales = ["nl-NL"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:bsn|burgerservicenummer)\s*[:#-]?\s*(\d{9})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "bsn_mod11"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "cpf.br"
safety_tier = "safe_default"
class = "custom:cpf"
enabled = true
locales = ["pt-BR"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:cpf|cadastro\s+de\s+pessoas\s+f[íi]sicas)\s*[:#-]?\s*(\d{3}\.?\d{3}\.?\d{3}-?\d{2})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "cpf_mod11"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "cnpj.br"
safety_tier = "safe_default"
class = "custom:cnpj"
enabled = true
locales = ["pt-BR"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:cnpj|cadastro\s+nacional\s+da\s+pessoa\s+jur[íi]dica)\s*[:#-]?\s*(\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "cnpj_mod11"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "nhs.uk"
safety_tier = "safe_default"
class = "custom:nhs_number"
enabled = true
locales = ["en-GB"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\bnhs\s*(?:number|no\.?|id)?\s*[:#-]?\s*(\d{3}[ -]?\d{3}[ -]?\d{4})\b'''
capture_groups = [1]

[recognizers.validator]
kind = "uk_nhs_mod11"

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "ssn.us"
safety_tier = "locale_gated"
class = "custom:ssn"
enabled = true
locales = ["en-US"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:ssn|social\s+security(?:\s+number)?|ss#)\s*[:#-]?\s*(\d{3}-\d{2}-\d{4}|\d{9})\b'''
capture_groups = [1]

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "nino.uk"
safety_tier = "locale_gated"
class = "custom:nino"
enabled = true
locales = ["en-GB"]

[recognizers.match]
kind = "regex"
# Prefix letters follow HMRC NINO format constraints: D/F/I/Q/U/V are
# excluded from both positions, O is excluded from the second position, and
# BG, GB, KN, NK, NT, TN, and ZZ are not allocated.
pattern = '''(?i)\b(?:nino|ni\s+number|national\s+insurance(?:\s+number)?)\s*[:#-]?\s*((?:[ACEHJLMOPRSWXY][ABCEGHJKLMNPRSTWXYZ]|B[ABCEHJKLMNPRSTWXYZ]|G[ACEGHJKLMNPRSTWXYZ]|K[ABCEGHJLMPRSTWXYZ]|N[ABCEGHJLMPRSWXYZ]|T[ABCEGHJKLMPRSTWXYZ]|Z[ABCEGHJKLMNPRSTWXY])\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D])\b'''
capture_groups = [1]

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

[[recognizers]]
id = "pan.in"
safety_tier = "locale_gated"
class = "custom:pan"
enabled = true
locales = ["en-IN", "hi-IN"]

[recognizers.match]
kind = "regex"
pattern = '''(?i)\b(?:pan|permanent\s+account\s+number|pan\s+card|pan\s+no|पैन)\s*[:#-]?\s*([A-Z]{3}[ABCFGHJLPT][A-Z]\d{4}[A-Z])\b'''
capture_groups = [1]

[recognizers.scoring]
base = 0.88
priority = 86

[recognizers.token]

# Postal collision warning (research-855 §Collision matrix conclusion):
# `postal.de` and `postal.us` both accept naked five-digit numeric strings.
# This is safe only when active locale chains keep DE and US projections apart.
# Avoid broad chains such as [global, en-US, de-DE] for postal-only workflows.
# Prefer a single active country locale, or split processing by locale_chain.
# Loader lint warns or fails strict mode when this collision class can co-fire.
[[recognizers]]
id = "postal.de"
safety_tier = "locale_gated"
class = "custom:postal_code"
cooperates_with = ["postal.us"]
enabled = true
locales = ["de-DE"]

[recognizers.match]
kind = "regex"
pattern = '''\b\d{5}\b'''

[recognizers.scoring]
base = 0.70
priority = 70

[recognizers.token]

# Postal collision warning (research-855 §Collision matrix conclusion):
# `postal.de` and `postal.us` both accept naked five-digit numeric strings.
# This is safe only when active locale chains keep DE and US projections apart.
# Avoid broad chains such as [global, en-US, de-DE] for postal-only workflows.
# Prefer a single active country locale, or split processing by locale_chain.
# Loader lint warns or fails strict mode when this collision class can co-fire.
[[recognizers]]
id = "postal.us"
safety_tier = "locale_gated"
class = "custom:postal_code"
cooperates_with = ["postal.de"]
enabled = true
locales = ["en-US"]

[recognizers.match]
kind = "regex"
pattern = '''\b\d{5}(-\d{4})?\b'''

[recognizers.scoring]
base = 0.70
priority = 70

[recognizers.token]
