OGuardAI
ArchitectureDecision Records

ADR 0006: NER as a Strict, Fail-Closed Requirement

A policy that requires NER blocks on an NER outage rather than silently falling back to the regex floor

Status: Accepted

Context

The strongest product claim is that the model never sees the real name, email, or customer number. Regex detects formats (email, phone, IBAN, card), but names, organizations, and locations, and any domain label such as a diagnosis, are found by NER. If NER is down or was not run and the runtime silently falls back to the regex-only floor, those entities pass through undetected. The output is a fail-open leak that looks like success. So a deployment that depends on NER needs a way to demand it and to fail closed when it is absent, not to quietly degrade.

The default backend is GLiNER (urchade/gliner_medium-v2.1, zero-shot), with spaCy as an alternative backend. The point of this decision is not which model runs, it is that when a policy requires NER, an outage must block.

Decision

NER can be made a strict, fail-closed requirement per policy, and a silent fallback to the regex floor is treated as "NER did not run" so the requirement still fails closed.

  • The requirement is a policy control. crates/policy DetectionControl.required_for names entity types that must be detected. requires_ner() is true when any required type is NER-backed (the built-in person, company, location, or a type introduced via ner_labels / ner_label_map).
  • A silent fallback does not count as NER. In crates/detector-client/src/merged.rs, MergedDetector carries a ner_required flag; when set, an NER failure propagates as an error instead of falling back. Critically, when the mode falls back to the built-in detector on an NER outage, the outcome reports ner_ran = false. So even a fallback that "succeeded" with regex-only is not mistaken for a real NER pass.
  • The runtime enforces it uniformly. crates/runtime/src/detection.rs (apply_policy_detection) receives that ner_ran flag and, if detection.requires_ner() is true and ner_ran is false, returns DetectionFailed. Every ingress (server transform, RAG, proxy) goes through this same check.
  • Zero-shot labels demand a zero-shot backend. requires_zero_shot_ner() covers a required type that only a zero-shot backend can produce (a custom ner_labels type, as opposed to the built-in person/company/location that spaCy also finds). If a non-zero-shot backend such as spaCy ran, that request fails closed rather than silently missing the custom label.
  • Required types are exempt from the confidence floor. A required_for type is kept even below min_confidence, because dropping it would leak it. The floor still drops non-required low-confidence spans.

This is opt-in. Without required_for, the default keeps the regex floor available so a deployment that does not run NER still works; the guarantee is there for the deployments that need it.

Consequences

  • A deployment that must guarantee the model never sees names or domain identifiers sets detection.required_for, and an NER outage then blocks the request instead of leaking.
  • The honest posture becomes stateable: "fail-closed when NER is required, extensible per policy," rather than an unqualified absolute over arbitrary undetected text.
  • Running NER as a required control couples request success to detector-service availability, which is the intended tradeoff: correctness over throughput for regulated workloads.
  • The service-level backend selection (which model loads) is separate from this guarantee. The fail-closed contract lives in the Rust runtime and keys off whether NER actually contributed to the result, not off which backend was configured.