AnonymizingTransport: seven PII transformations, one wrapper.
AnonymizingTransport wraps any FhirTransport and applies seven deterministic de-identification transformations to every FHIR response — before the data reaches your application layer. Powered by the io.cognovis.de-identification.de@0.11.0 IG.
Transformations
Seven deterministic de-identification steps.
Every FHIR response — whether a single resource or a Bundle — passes through all seven transformations in order. DELETE responses are returned unchanged (they carry no resource body).
Step 1
ID Hashing
SHA-256(salt:id) → anon- + first 12 hex chars. Deterministic: same salt + id always produces the same hash.
Step 2
PII Field Removal
Structured PII fields (name, address, telecom, identifier, etc.) are removed per the PII_FIELDS catalogue from the de-identification IG.
Step 3
Display Replacement
All display fields on FHIR reference objects ({ reference, display }) are replaced with [NAME].
Step 4
Reference Rewriting
All reference strings of the form ResourceType/id (relative or absolute URL) have the id segment replaced with the same deterministic hash as step 1.
Step 5
Free-text Scrubbing
Configured free-text fields are scanned with regex patterns for titled names, dates, phone numbers, and KV-numbers — replacing matches with tokens.
Step 6
Date Shift
date/dateTime/Period fields listed in DATE_SHIFT_FIELDS are shifted by a deterministic number of days (1–365) derived from the salt. Time-of-day parts are preserved unchanged.
Step 7
birthDate Safe Harbor
Fields in BIRTH_DATE_SAFE_HARBOR_FIELDS (e.g. Patient.birthDate) are truncated to year-only per HIPAA Safe Harbor §164.514(b)(2)(ii).
FHIR response
|
| [Step 1] Hash resource.id: SHA-256(salt:id) → 'anon-' + 12hex
| [Step 2] Strip PII_FIELDS: delete name, address, telecom, identifier, ...
| [Step 3] Replace display fields on reference objects → '[NAME]'
| [Step 4] Rewrite reference strings: 'Patient/p-123' → 'Patient/anon-...'
| [Step 5] Scrub free-text fields: regex patterns → [NAME], [DATE], [TEL], [KV-NR]
| [Step 6] Shift date/dateTime/Period fields in DATE_SHIFT_FIELDS by salt-derived days
| [Step 7] Truncate BIRTH_DATE_SAFE_HARBOR_FIELDS to year-only (HIPAA Safe Harbor)
v
anonymized response (safe for Zone-C processing) Configuration
Constructor signature and salt setup.
AnonymizingTransport requires a salt — either passed explicitly or read from POLARIS_ANON_SALT. If neither is provided, the constructor throws FhirDeAnonymizingError('missing_salt') synchronously.
// Constructor signature
new AnonymizingTransport(
innerTransport: FhirTransport,
salt?: string, // explicit salt (overrides env var)
options?: AnonymizingTransportOptions // optional: requireIgVersion
)
interface AnonymizingTransportOptions {
requireIgVersion?: string // throw if bundled IG version doesn't match
} Via piiMode (recommended)
import { createFhirDeClient } from '@polaris/sdk/fhir'
// POLARIS_ANON_SALT must be set in the environment
const client = createFhirDeClient({
baseUrl: 'https://aidbox.example.com/fhir',
transport: myFetchTransport,
piiMode: 'anonymized',
})
// Internally creates: new AnonymizingTransport(transport) Direct instantiation
import { AnonymizingTransport } from '@polaris/sdk/fhir'
// Explicit salt (test/dev usage)
const t = new AnonymizingTransport(innerTransport, 'my-salt')
// From env var (production usage)
// process.env.POLARIS_ANON_SALT = '...'
const t = new AnonymizingTransport(innerTransport)
// With IG version pin
const t = new AnonymizingTransport(
innerTransport,
undefined, // use env var
{ requireIgVersion: '0.11.0' }
) Generating POLARIS_ANON_SALT
Use a stable 64-character hex string. Generate once and store as a deployment secret:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" The salt must be stable across restarts — changing it invalidates all previously hashed IDs.
Transformation 1
ID hashing — deterministic pseudonymization.
Every resource id is replaced with SHA-256(salt:id) truncated to 12 hex chars, prefixed with anon-. The same salt + id always produces the same hash — so hashed IDs remain consistent across requests and restarts, as long as the salt does not change.
Algorithm
hashId(salt, id):
digest = SHA-256(salt + ':' + id)
return 'anon-' + digest.substring(0, 12)
// Example:
hashId('my-salt', 'patient-123')
// → 'anon-3f8a2b1c94d7' (12 hex chars) Before / after
// Before:
{ resourceType: 'Patient', id: 'patient-123', ... }
// After:
{ resourceType: 'Patient', id: 'anon-3f8a2b1c94d7', ... }
// Bundle entry fullUrl is also updated:
// Before: 'Patient/patient-123'
// After: 'Patient/anon-3f8a2b1c94d7' Transformation 2
PII field removal — IG-derived catalogue.
Structured PII fields are deleted from FHIR resources according to the PII_FIELDS catalogue sourced from io.cognovis.de-identification.de@0.11.0. Only the listed fields are removed — all other fields are preserved unchanged.
| Resource type | Removed fields |
|---|---|
| Account | name, owner |
| CarePlan | author |
| ChargeItem | enterer, performingOrganization |
| Coverage | identifier, payor |
| DocumentReference | content[].attachment.url |
| ExplanationOfBenefit | disposition |
| Location | address, description, name, position |
| MedicationDispense | dosageInstruction[].text |
| MedicationRequest | dosageInstruction[].text |
| Patient | address, identifier, name, telecom |
| Practitioner | address, birthDate, name, telecom |
| Provenance | agent |
| Specimen | processing[].description |
Note: partial array removal
For array paths like dosageInstruction[].text, the text sub-field is removed from each array element but the rest of the element (route, timing, doseAndRate, etc.) is preserved. Only the named sub-field is deleted.
Transformation 3
Display field replacement.
In FHIR, reference objects can carry a human-readable display string alongside the reference pointer. These display strings often contain patient or practitioner names. Every display field that co-occurs with a reference field in the same object is replaced with the token [NAME].
// Before:
subject: {
reference: 'Patient/p-123',
display: 'Max Mustermann' // ← PII
}
// After:
subject: {
reference: 'Patient/anon-3f8a2b1c94d7', // reference also rewritten (step 4)
display: '[NAME]'
} Transformation 4
Reference string rewriting.
All reference string values anywhere in the resource graph are rewritten to replace the id segment with the same hash as step 1. Both relative references (Patient/p-123) and absolute references (https://server/fhir/Patient/p-123) are handled. The hash is consistent: Patient/shared-id in a relative reference will produce the same hash as https://server/fhir/Patient/shared-id.
Relative references
// Before:
{ reference: 'Patient/p-abc-123' }
// After:
{ reference: 'Patient/anon-3f8a2b1c94d7' }
// Pattern matched: /^([A-Za-z]+)\/(.+)$/
// Group 1 = resource type (preserved)
// Group 2 = id (hashed) Absolute references
// Before:
{ reference: 'https://aidbox.example.com/fhir/Patient/p-abc-123' }
// After:
{ reference: 'https://aidbox.example.com/fhir/Patient/anon-3f8a2b1c94d7' }
// Pattern matched: /^(https?:\/\/.../)([A-Za-z]+)\/([^/]+)$/
// Group 1 = base URL (preserved)
// Group 2 = resource type (preserved)
// Group 3 = id (hashed) Transformation 5
Free-text scrubbing — regex patterns.
Configured free-text fields are scanned with four regex patterns from the de-identification IG. Matches are replaced with placeholder tokens. Only the fields listed in FREE_TEXT_FIELDS are scanned — clinical text in other fields is untouched.
Scrubbed fields (FREE_TEXT_FIELDS)
| Resource | Field path |
|---|---|
| AllergyIntolerance | note[].text |
| CarePlan | note[].text |
| ChargeItem | note[].text |
| DiagnosticReport | conclusion |
| DocumentReference | description |
| MedicationAdministration | dosage.text |
| Observation | valueString |
Pattern catalogue (FREE_TEXT_PATTERNS)
de-titled-name
German titled names: Dr. / Hr. / Fr. + capitalized name
Pattern: (?:Dr\.|Hr\.|Fr\.)\s+[A-Z...][a-z...]... Replacement: [NAME] Example: "Dr. Mustermann" → "[NAME]"
de-date
German date format
Pattern: \b\d{1,2}\.\d{1,2}\.\d{4}\b
Replacement: [DATE]
Example: "15.03.1985" → "[DATE]" de-german-phone
German phone numbers (+49 or 0 prefix)
Pattern: (?<!\w)(\+49|0)[\d\s\-/]{7,}
Replacement: [TEL]
Example: "+49 30 1234567" → "[TEL]" de-kvnr
German health insurance number (KVNR)
Pattern: \b[A-Z]\d{9}\b
Replacement: [KV-NR]
Example: "A123456789" → "[KV-NR]" Scope limitation
Bare name bigrams (Firstname Lastname without a title) are intentionally excluded from the patterns. This avoids false positives on clinical terms like "Diabetes Mellitus" or "Akute Otitis Media". Only titled names (Dr., Hr., Fr.) are caught.
Transformation 6
Date shift — deterministic temporal displacement.
date/dateTime/Period fields listed in DATE_SHIFT_FIELDS are shifted by a deterministic number of days (1–365) derived from the salt: SHA-256(salt + ':dateshift') → first 8 hex chars → mod 365 + 1. The same salt always produces the same shift. The time-of-day part of dateTime values is preserved unchanged. Fields absent from the resource are silently skipped.
Algorithm
deriveShiftDays(salt):
hex = SHA-256(salt + ':dateshift')
return (parseInt(hex.slice(0, 8), 16) % 365) + 1
// Shift applied to each configured date field:
// '2024-03-15' → '2024-08-27' (shift = 165 days)
// '2024-03-15T14:30:00Z' → '2024-08-27T14:30:00Z' Example fields shifted
Encounter.period.start / .end
Observation.effectiveDateTime
Observation.effectivePeriod.start / .end
Condition.onsetDateTime
Procedure.performedDateTime
Specimen.processing[].timeDateTime
... (all from DATE_SHIFT_FIELDS catalogue) Transformation 7
birthDate Safe Harbor — HIPAA year-only truncation.
Fields listed in BIRTH_DATE_SAFE_HARBOR_FIELDS (e.g. Patient.birthDate) are truncated to year-only per HIPAA Safe Harbor §164.514(b)(2)(ii). Any FHIR date value (YYYY, YYYY-MM, or YYYY-MM-DD) is reduced to just the 4-digit year. Values that do not match the FHIR date format are returned unchanged.
// Before:
{ resourceType: 'Patient', birthDate: '1980-01-15' }
// After:
{ resourceType: 'Patient', birthDate: '1980' }
// All FHIR date cardinalities truncated to year:
// '1980-01-15' → '1980'
// '1980-01' → '1980'
// '1980' → '1980' (already year-only, unchanged) Typed errors
FhirDeAnonymizingError — code + docUrl.
AnonymizingTransport throws FhirDeAnonymizingError (not a raw Error) for all configuration failures. Each error carries a typed code and a docUrl linking to the relevant docs section.
// Error class definition
class FhirDeAnonymizingError extends Error {
readonly code: FhirDeErrorCode // 'missing_salt' | 'ig_version_mismatch' | 'unsupported_resource_type'
readonly docUrl: string
}
// Import
import { FhirDeAnonymizingError, FHIR_DE_ERROR_CODES } from '@polaris/sdk/fhir' | Error code | When thrown | Resolution |
|---|---|---|
| missing_salt | Constructor — no salt provided and POLARIS_ANON_SALT not set | Set POLARIS_ANON_SALT in the environment or pass the salt explicitly as the second argument. |
| ig_version_mismatch | Constructor — requireIgVersion option set but doesn't match bundled IG version | Update requireIgVersion to match the bundled version (0.11.0), or upgrade the package. |
| unsupported_resource_type | Defined for future use — not currently thrown (pass-through is intentional) | Rely on pass-through for forward compatibility — unknown resource types return unchanged. |
Catching typed errors
import {
AnonymizingTransport,
FhirDeAnonymizingError,
} from '@polaris/sdk/fhir'
try {
const t = new AnonymizingTransport(inner)
} catch (err) {
if (err instanceof FhirDeAnonymizingError) {
console.error(err.code) // 'missing_salt'
console.error(err.docUrl) // link to docs
// err.code is typed — switch is exhaustive
switch (err.code) {
case 'missing_salt':
// set up salt...
break
case 'ig_version_mismatch':
// upgrade package...
break
}
}
} IG version pinning
import { AnonymizingTransport } from '@polaris/sdk/fhir'
import { DE_IDENTIFICATION_IG_VERSION } from '@polaris/fhir-de'
// Current bundled version: 0.11.0
// Pin at startup so version drift surfaces immediately:
const t = new AnonymizingTransport(
inner,
undefined, // use POLARIS_ANON_SALT env var
{ requireIgVersion: '0.11.0' }
)
// Throws ig_version_mismatch if package is
// upgraded to a newer IG before this line is updated. Supported resource types
Pass-through for unknown types.
Resources with a resourceType not listed in either PII_FIELDS or FREE_TEXT_FIELDS still pass through transformations 1 (ID hashing), 3 (display replacement), and 4 (reference rewriting). Transformations 2 (PII field removal) and 5 (free-text scrubbing) are no-ops for unknown types. Transformations 6 (date shift) and 7 (birthDate safe harbor) only apply to resource types listed in DATE_SHIFT_FIELDS and BIRTH_DATE_SAFE_HARBOR_FIELDS respectively — all other types pass through those steps unchanged. This is intentional — the transport fails open for new FHIR resource types that are not yet in the de-identification IG catalogue.
Known resource types (IG 0.11.0)
PII_FIELDS catalogue
- Account
- CarePlan
- ChargeItem
- Coverage
- DocumentReference
- ExplanationOfBenefit
- Location
- MedicationDispense
- MedicationRequest
- Patient
- Practitioner
- Provenance
- Specimen
FREE_TEXT_FIELDS catalogue
- AllergyIntolerance
- CarePlan
- ChargeItem
- DiagnosticReport
- DocumentReference
- MedicationAdministration
- Observation
See also
de-identification ig
IG catalogue reference
All three Library manifests: PII fields, free-text patterns, quasi-id k-floors.
fhir sdk
Full FHIR client reference
createFhirDeClient, FhirTransport SPI, ResourceClient, error classes.
llm gateway
Zone-A to Zone-C boundary
Use AnonymizingTransport before crossing the Zone-A→Zone-C boundary to the LLM gateway.