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/policyDetectionControl.required_fornames 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 vianer_labels/ner_label_map). - A silent fallback does not count as NER. In
crates/detector-client/src/merged.rs,MergedDetectorcarries aner_requiredflag; 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 reportsner_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 thatner_ranflag and, ifdetection.requires_ner()is true andner_ranis false, returnsDetectionFailed. 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 customner_labelstype, 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_fortype is kept even belowmin_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.