Deidentification — Safe Harbor Removal

← Back to Platform Architecture

Deidentification — Safe Harbor Removal

Overview

Deidentification removes 18 Protected Health Information identifiers from precedent data using Safe Harbor algorithm (45 CFR § 164.514(b)). When: User exports precedent with ?deidentify=true flag Who: Admin users only (access controlled) Result: HIPAA-compliant de-identified dataset safe for sharing

Safe Harbor Algorithm (45 CFR § 164.514(b))

Principle

Remove or replace 18 direct and quasi-identifiers. Data is considered de-identified if:
  • All 18 identifiers removed, AND
  • Data custodian certifies expert determination
  • The 18 Identifiers (Safe Harbor)

    Direct Identifiers (Remove Completely)

  • Name → Remove
  • Address (street, city, state, ZIP) → Remove
  • Phone/Fax Numbers → Remove
  • Email Address → Remove
  • Social Security Number (SSN) → Remove
  • Medical Record Numbers → Remove
  • Health Insurance IDs → Remove
  • Account Numbers → Remove
  • Certificate/License Numbers → Remove
  • Vehicle IDs/Plate Numbers → Remove
  • Web URLs → Remove
  • IP Addresses → Remove
  • Biometric ID Numbers → Remove
  • Photographic Images → Remove or blur
  • Quasi-Identifiers (Generalize or Remove)

  • Age/Date of Birth → Generalize to age group (e.g., "40s") or year of birth (e.g., "Born 19xx")
  • Dates (except year) → Remove specific dates, keep only year
  • Geographic Data → Generalize to state/regional level
  • Unique ID Numbers (case control codes, medical record sequence numbers) → Remove
  • De-Identification Algorithm

    Input

    Original precedent:
    {
      "id": "precedent-uuid",
      "precedent_name": "John Doe's Cigna IOP Appeal Victory",
      "payer": "Cigna",
      "denial_type": "Frequency Limit",
      "loc": "IOP",
      "policy_reference": {
        "document_url": "https://cigna.internal/john-doe-policies",
        "exact_language": "Patient ID ACC123456 denied"
      },
      "narrative": "John Doe (DOB 1985-06-15) with GAF 35 appealed denial received 2024-04-14",
      "denial_trigger": "Patient exceeded 120-day frequency limit",
      "evidence_kit": {
        "required": ["Clinical notes", "Psychiatric evaluation from 2024-04-10"]
      },
      "outcome": "Won",
      "time_to_resolution_days": 14,
      "created_at": "2024-04-01T09:30:00Z"
    }

    Processing

    #### Phase 1: Direct Identifier Removal
    // Remove direct identifiers completely
    const deidentify = (precedent: Precedent): DeidentifiedPrecedent => {
      return {
        // Keep these (not direct identifiers)
        id: null,  // Remove
        precedent_name: "[De-identified precedent]",  // Remove personal name
        payer: precedent.payer,  // Keep (not identifiable)
        denial_type: precedent.denial_type,  // Keep
        loc: precedent.loc,  // Keep
        
        // Remove from policy_reference
        policy_reference: {
          ...precedent.policy_reference,
          document_url: null,  // Remove URL
          exact_language: precedent.policy_reference.exact_language
            .replace(/ACC\d{6,}/g, '[MRN]')  // Mask MRN
            .replace(/\d{3}-\d{2}-\d{4}/g, '[SSN]'),  // Mask SSN
        },
        
        // Remove from narrative
        narrative: precedent.narrative
          .replace(/John Doe/g, '[Patient Name]')
          .replace(/DOB 1985-06-15/g, '[DOB]')
          .replace(/2024-04-14/g, '[Date]'),
        
        // Remove specific dates from evidence_kit
        evidence_kit: {
          required: precedent.evidence_kit.required.map(item =>
            item.replace(/2024-04-10/g, '[Date]')
          ),
        },
        
        outcome: precedent.outcome,  // Keep (outcome is not identifiable)
        time_to_resolution_days: precedent.time_to_resolution_days,  // Keep (not identifiable)
        
        // Generalize dates
        created_at_year: 2024,  // Keep year only
      };
    };
    #### Phase 2: Quasi-Identifier Generalization
    // Generalize quasi-identifiers
    const generalize = (precedent: DeidentifiedPrecedent): SafeHarborCompliant => {
      return {
        ...precedent,
        
        // Age: convert to age group
        age_group: calculateAgeGroup(precedent.date_of_birth),  // "40s" or "40-49"
        
        // Dates: remove day/month, keep year only
        created_year: 2024,
        
        // Geographic: keep state, remove zip/city
        state: 'WA',  // Generalized from ZIP 98101
        
        // Any quasi-identifying numbers removed
        patient_id: null,
      };
    };
    #### Phase 3: Output Safe Harbor-compliant data:
    {
      "precedent_name": "[De-identified precedent]",
      "payer": "Cigna",
      "denial_type": "Frequency Limit",
      "loc": "IOP",
      "policy_reference": {
        "exact_language": "Patient ID [MRN] denied"
      },
      "narrative": "[Patient Name] (DOB: [DOB]) with GAF 35 appealed denial received [Date]",
      "denial_trigger": "Patient exceeded 120-day frequency limit",
      "evidence_kit": {
        "required": ["Clinical notes", "Psychiatric evaluation from [Date]"]
      },
      "outcome": "Won",
      "time_to_resolution_days": 14,
      "created_year": 2024,
      "state": "WA",
      "age_group": "40s",
      "deidentified_at": "2024-04-14T10:30:00Z",
      "deidentified_by": "admin-user-uuid"
    }

    Implementation

    Middleware (Deidentification)

    // src/middleware/deidentify.ts
    
    

    middleware.use((req, res, next) => { req.deidentify = req.query.deidentify === 'true'; if (req.deidentify) { // Only admins can deidentify if (req.context.user.role !== 'admin') { return res.status(403).json({ status: 'error', message: 'Deidentification requires admin role' }); } // Mark context for route handlers req.context.deidentify = true; } next(); });

    Route Handler

    // src/routes/precedents.ts
    
    

    router.get('/api/precedents/:id', tenant, async (req, res) => { const precedent = await db.query( 'SELECT FROM precedent_objects WHERE id = $1 AND tenant_id = $2', [req.params.id, req.context.tenant_id] ); if (req.context.deidentify) { // Apply Safe Harbor removal return res.json(deidentifyPrecedent(precedent)); } return res.json(precedent); });

    router.get('/api/precedents', tenant, async (req, res) => { const precedents = await db.query( 'SELECT FROM precedent_objects WHERE tenant_id = $1', [req.context.tenant_id] ); if (req.context.deidentify) { // Apply Safe Harbor to all results return res.json(precedents.map(p => deidentifyPrecedent(p))); } return res.json(precedents); });

    API Usage

    Non-Deidentified (Default)

    GET /api/precedents/123 HTTP/1.1
    
    

    Response: Full precedent with all identifiers

    { "id": "precedent-uuid", "precedent_name": "John Doe's IOP Appeal", "narrative": "John Doe (DOB 1985-06-15) with GAF 35..." }

    De-Identified

    GET /api/precedents/123?deidentify=true HTTP/1.1
    
    

    Response: Safe Harbor-compliant

    { "id": null, "precedent_name": "[De-identified precedent]", "narrative": "[Patient Name] (DOB [DOB]) with GAF 35...", "deidentified_at": "2024-04-14T10:30:00Z", "deidentified_by": "admin-user-uuid" }

    Audit Trail

    Every deidentification is logged:
    INSERT INTO precedent_audit_log (
      precedent_id, event_type='deidentified',
      user_id, tenant_id,
      event_data={
        "removed_identifiers": ["name", "dob", "mrn", "phone", "url"],
        "compliant_with": "HIPAA Safe Harbor",
        "deidentified_at": "2024-04-14T10:30:00Z"
      }
    );

    Expert Determination (42 CFR § 164.514(b)(1))

    Certification Statement:

    > "I certify that the de-identified health information that is disclosed has been reviewed and conforms with the requirements in 45 CFR § 164.514(b) for de-identified health information."

    Stored in:
    INSERT INTO compliance_attestations (
      type='expert_determination_deidentification',
      certified_by='expert-name',
      certified_at=NOW(),
      attestation_text='...'
    )

    Re-Identification Risk

    Residual Risks (Acknowledge but Accept)

    Even after Safe Harbor removal, risk exists: