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

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

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