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:The 18 Identifiers (Safe Harbor)
Direct Identifiers (Remove Completely)
Quasi-Identifiers (Generalize or 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:- Linkage attacks — Combining with public data (e.g., demographic + diagnosis)
- Inference attacks — Deducing identity from context Mitigation:
- No exporting combined with named lists
- No small population data (< 10 cases)
- Recommendation to use only in secure environments
- [ ] All 18 identifiers identified and removed/generalized
- [ ] Dates generalized to year only
- [ ] Geographic data generalized to state level
- [ ] Age/DOB converted to age groups
- [ ] No quasi-identifiers in combination that could re-identify
- [ ] Expert determination certification stored
- [ ] Deidentification logged to audit trail
- [ ] Access limited to admin role
- [ ] ?deidentify flag must be explicit (not default)
Compliance Checklist
Testing
Test Case: Name Removal
test('removes patient name from narrative', () => {
const precedent = {
narrative: 'John Doe was denied coverage'
};
const deidentified = deidentifyPrecedent(precedent);
expect(deidentified.narrative).not.toContain('John Doe');
expect(deidentified.narrative).toContain('[Patient Name]');
});
Test Case: Date Generalization
test('generalizes dates to year', () => {
const precedent = {
created_at: '2024-04-14T10:30:00Z'
};
const deidentified = deidentifyPrecedent(precedent);
expect(deidentified.created_year).toBe(2024);
expect(deidentified.created_at).toBeNull();
});
See also
Code reference
src/middleware/deidentify.ts — Deidentification middlewaresrc/services/deidentify.ts — Safe Harbor algorithm implementationsrc/routes/precedents.ts — GET endpoints (with ?deidentify flag)src/routes/appeals.ts — Export endpoints (with ?deidentify flag)