Configuration

One file, tacit_deid.yaml, that you own. load_config(path) returns an SdkConfig; the engine reads engine_config() and the model client reads llm_config(). Credentials are never in the file.

Fields

A file missing model or allowed_endpoint_hosts, or with a non-list value for either, fails at load, before the pipeline runs.

The endpoint allowlist

The large language model (LLM) seam is the one place the transcript is a model’s input, so where that call goes is bounded up front. When the client is constructed, before any Protected Health Information (PHI) is sent, the SDK resolves the host the configured model would call and checks it against allowed_endpoint_hosts. A host passes when it equals an entry or is a subdomain of one, with a spoof guard. Otherwise EndpointNotAllowedError is raised and no client exists. The resolved host is recorded in the artifact’s audit log. How the host is resolved: from base_url when set; for a bedrock/ model, bedrock-runtime.<region>.amazonaws.com; for ollama, localhost; for openai, api.openai.com; otherwise the provider tag itself, still checked against the list.
The allowlist constrains where the request goes. It does not inspect what the endpoint does with the text. Point model at a deployment inside your own tenancy.

Detector stacks

detectors names the Stage-0 members. Their spans are unioned; on an overlap a structured entity beats a generic one. The default is [presidio, gliner-pii-open]. In 294 synthetic health records, Presidio alone found 77.4% of the planted identifiers and the default stack found 97.0%; in 20 synthetic doctor and patient dialogs, 44.9% against 65.2% (Tacit’s detector study of 27 September 2026, one run each). The model catches names, occupations, organisations, and record numbers Presidio does not see. No GLiNER model is bundled: the default needs the gliner extra, and gliner-pii-open downloads about 0.7 GB on first use, or loads from a local directory named by TACIT_DEID_GLINER_PII_OPEN_MODEL. Without the extra, the first command stops and names the fix. Every detector obeys the same guards. A clinician role (“the GP”, “a consultant”) and a generic department or facility (“A&E”, “gastroenterology”) stay in the text, because they say who treated the patient and where; a patient’s own occupation (“I’m a doctor”) and a named institution (“Christchurch Hospital”) are replaced. To run without a model detector, set detectors: [presidio]. A run or roundtrip without a model member prints one warning to stderr that names the gap.

What each stack detects

What Stage 0 misses is the judge loop’s to catch, and a mid-sized judge does not always catch it. If your transcripts carry occupations, record numbers, or organisations, stack gliner-pii. Stack it before you turn on send_tokenized_transcript, because then the cleaned transcript itself crosses, not only the beats drawn from it. Formats the shipped stack does not know, your record numbers and ward names, go in custom_identifiers; see Your own identifier formats. Every detector obeys the same three rules, whatever its model scores: a bare role word that labels a turn (Doctor:, [patient]) is never an identifier, a generic work word (work, job, site) is never an occupation, and a relative time phrase (last night, Monday, six months ago) is never a date. A label that holds a name (Dr Salesa:) is still replaced.

Environment variables

run and roundtrip take --env-file PATH to load these from a KEY=VALUE file first; a variable already set keeps its value.

Engine knobs, in one line each

boundary_k is written into the audit log and enforced by singling-out. prevalence_floor is the clinical-rarity cutoff; it flags only a condition the tables know and rate below the floor everywhere. Besides the national tables, it reads about 10,800 names and synonyms of disorders Orphanet confirms are rare, each at the highest prevalence Orphanet records for it anywhere, so a beat naming Marfan syndrome or Huntington’s disease is dropped. Orphanet records mostly European figures: a condition common elsewhere, such as malaria, counts as rare in the four supported countries. max_generalization_loops bounds the judge loop; exceeding it is a refusal, not a pass. mattr_window shapes one distributional target. Lowering boundary_k or raising the loop cap to make a refused record pass defeats the control that refused it.