Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Detection behavior

Two deliberate behaviors of KINDI's detection pipeline routinely read as bugs the first time you meet them. They are not bugs; each one is doing precision or safety work. This page explains what each does, why, and how to write code that accounts for it. A third section covers the retired legacy alias spans, which you may still meet in stored responses.

1. Checksum validation: shape is not enough

For identifier types with a published check digit, KINDI verifies the check digit and drops the span when it fails. This is on by default.
TypeCheck
NATIONAL_ID / RESIDENCE_PERMITThe Saudi position-doubling Luhn variant over all 10 digits
IBANISO 13616 mod-97
CREDIT_CARDStandard mod-10 Luhn (always enforced)
A 10-digit number that merely looks like a national ID, say 1012345678, fails its check digit and is deliberately not masked. That is what keeps precision high on 10-digit numbers that are really order references, timestamps, or part numbers. TAX_ID stays shape-only; there is no authoritative public ZATCA check-digit specification.
Building test fixtures? Use checksum-valid values or your test will look like a detection miss. These pass:
TypeChecksum-valid example
NATIONAL_ID1012345672 or 1052338942
RESIDENCE_PERMIT2012345670
IBANSA03 8000 0000 6080 1016 7519

Files are checksum-lenient

The strictness above applies to the born-digital text endpoints (/mask, /redact), where your input is exact and precision is the goal. File redaction deliberately relaxes it: OCR routinely mangles one digit of a scanned national ID or IBAN, and a mangled-but-obviously-an-ID number is exactly what must not survive into a shared document. Inside a file, an identifier-shaped number is redacted even when its check digit fails.
One rule of thumb: strict for API text (precision), lenient for extracted document text (privacy-first).

2. Unicode anti-evasion: invisible characters are removed first

Before any detection layer runs, KINDI normalizes the input:
  • Deletes Unicode format characters (category Cf): zero-width spaces and joiners, bidi embeds/isolates/marks, the soft hyphen, the BOM, and Arabic format marks, plus invisible variation selectors.
  • Folds fullwidth digits (123) to their ASCII equivalents, one-for-one.
Without this, a single zero-width space pasted into the middle of an ID (they hitch a ride in text copied from PDFs, RTL documents, and chat apps constantly) defeats every digit pattern and the value flows through unmasked.
Offsets always refer to your original text. The normalization happens on an internal copy; every start/end in spans is projected back onto the exact bytes you sent, so text[start:end] always yields the detected surface, invisible characters included. You never need to pre-clean input, and you must not re-derive offsets from a cleaned copy of your own.

3. Legacy alias spans: retired

Seven Saudi-specific types used to appear twice in /mask's spans, at identical offsets: once under the canonical name and once under an older legacy alias kept for backwards compatibility. That dual-emit was retired in August 2026. spans now carries one entry per detected entity, canonical names only, and these legacy names no longer appear:
CanonicalRetired legacy sibling
NATIONAL_IDSAUDI_NATIONAL_ID
RESIDENCE_PERMITIQAMA
PASSPORTSAUDI_PASSPORT
IBANIBAN_SA
BUSINESS_IDCR_NUMBER
TAX_IDZAKAT_NUMBER
MRNMRN_MEDICAL
What this means for your code:
  • pii_count now equals len(spans). Prefer pii_count as the entity count regardless; it has always been the contract.
  • If you grep spans for a legacy name from the right-hand column, switch to the canonical name.
  • Stored responses from before the retirement still contain alias siblings; when replaying those, de-duplicate by (start, end) and keep the first entry.
  • Entity toggles already operate on canonical names only, so nothing changes there.
  • A server-side compatibility switch can temporarily restore dual-emit during the transition; contact support if your integration still depends on the legacy names.
/redact was never affected: its enumerated placeholders are canonical-name only. See Common mistakes #9 for the replay fix patterns and the /mask reference for the full contract.