F POLARIS · FHIR
sdk · fhir · anonymizing-transport

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.24 IG.

IG: io.cognovis.de-identification.de@0.11.24

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 } or { identifier, 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], [PHONE], [KVNR], ...
    | [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.24' }
)

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.24. 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, policyHolder
ExplanationOfBenefit disposition
Location address, description, name, position
Patient address, birthDate, contact, identifier, name, photo, telecom
Practitioner address, identifier, name, photo, telecom
Provenance agent
RelatedPerson address, name, photo, telecom

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 or a logical identifier. These display strings often contain patient or practitioner names. Every display field that co-occurs with a reference or identifier 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]'
}

// Identifier-only references are handled too:
payor: {
  identifier: { system: 'https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR', value: '123456789' },
  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
Patient text.div

Pattern catalogue (FREE_TEXT_PATTERNS)

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]"

date-de

German date format

Pattern: \b\d{1,2}\.\d{1,2}\.\d{4}\b
Replacement: [DATE]
Example: "15.03.1985" → "[DATE]"

phone-de

German phone numbers (+49 or 0 prefix)

Pattern: (?<!\w)(\+49|0)[\d\s\-/]{7,}
Replacement: [PHONE]
Example: "+49 30 1234567" → "[PHONE]"

kvnr-kvid10

German health insurance number (KVNR)

Pattern: \b[A-Z]\d{9}\b
Replacement: [KVNR]
Example: "A123456789" → "[KVNR]"

lanr-bsnr

Nine-digit LANR or BSNR values

Pattern: \b\d{9}\b
Replacement: [LANR-BSNR]
Example: "123456789" → "[LANR-BSNR]"

kim-mail

KIM mail addresses

Pattern: *@*.kim.telematik
Replacement: [KIM-MAIL]
Example: "praxis@example.kim.telematik" → "[KIM-MAIL]"

de-lanr / de-bsnr

Labeled LANR and BSNR values

Pattern: LANR: 123456789 / BSNR: 123456789
Replacement: [LANR] / [BSNR]

de-age-adjective / de-age-predicative

German age phrases

Examples: "42-jaehrige", "42 Jahre alt"
Replacement: [AGE]

de-icd-10 / de-versnr / de-kvnr-labeled

ICD-10 codes, insurance numbers, and labeled KVNR values

Examples: "E11.9", "123-456789-12345-12", "KVNR: A123456789"
Replacement: [ICD] / [VERSNR] / [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.24), 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.24
// Pin at startup so version drift surfaces immediately:
const t = new AnonymizingTransport(
  inner,
  undefined,  // use POLARIS_ANON_SALT env var
  { requireIgVersion: '0.11.24' }
)
// 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.24)

PII_FIELDS catalogue

  • Account
  • CarePlan
  • ChargeItem
  • Coverage
  • ExplanationOfBenefit
  • Location
  • Patient
  • Practitioner
  • Provenance
  • RelatedPerson

FREE_TEXT_FIELDS catalogue

  • AllergyIntolerance
  • CarePlan
  • ChargeItem
  • DiagnosticReport
  • DocumentReference
  • MedicationAdministration
  • Observation
  • Patient