HealthProbe / HealthProbe / Doc / Implementation Guide.md
1 contributor
639 lines | 22.041kb

HealthProbe – Technical Implementation Guide

Document Purpose: Step-by-step guide for iOS app implementation
Target Audience: iOS developers
Prerequisite Reading: "Complete Specification & Motivations"


⚠️ Privacy Directives — Mandatory

The following rules apply to all code, logs, examples, tests, and documentation in this project:

  • No credentials — no API keys, tokens, passwords, or signing certificates
  • No personal data — no names, email addresses, phone numbers, or dates of birth
  • No device identifiers — no UDIDs, serial numbers, advertising IDs, or device names
  • No account identifiers — no Apple IDs, iCloud account info, or CloudKit record IDs
  • No raw health values in the repository — do not include real health records, measurements, or workouts in code, tests, logs, examples, or documentation. The app may optionally store a user's raw samples locally on-device for forensic backup, but nothing real belongs in this repo.
  • No location data — no GPS coordinates or location history
  • No recognizable patterns — no logs or exports where combining fields could identify a person or device

If adding examples, use clearly synthetic data: "Device: iPhone-TESTDEVICE", "User: Test User", "2000-01-01".


1. HealthKit Integration

1.1 Permission Model

import HealthKit

class HealthKitManager {
    static let shared = HealthKitManager()
    let healthStore = HKHealthStore()
    
    let typesToRead: Set<HKSampleType> = [
        HKWorkoutType.workoutType(),
        HKQuantityType.quantityType(forIdentifier: .heartRate)!,
        HKQuantityType.quantityType(forIdentifier: .stepCount)!,
        HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
        HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)!,
        HKActivitySummaryType.activitySummaryType(),
    ]
    
    func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) {
        healthStore.requestAuthorization(toShare: [], read: typesToRead) { success, error in
            completion(success, error)
        }
    }
}

1.2 Anchored Query Pattern

Purpose: Efficient incremental queries that only fetch changes since last check

class AnchoredQueryManager {
    let defaults = UserDefaults(suiteName: "group.com.healthprobe.data")
    
    func loadAnchor(for sampleType: HKSampleType) -> HKQueryAnchor? {
        guard let data = defaults?.data(forKey: "anchor_\(sampleType.identifier)") else {
            return nil
        }
        return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
    }
    
    func saveAnchor(_ anchor: HKQueryAnchor, for sampleType: HKSampleType) {
        let data = try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
        defaults?.set(data, forKey: "anchor_\(sampleType.identifier)")
    }
    
    func executeAnchoredQuery(
        sampleType: HKSampleType,
        completion: @escaping ([HKSample], [HKDeletedObject], HKQueryAnchor) -> Void
    ) {
        let anchor = loadAnchor(for: sampleType) ?? HKQueryAnchor(byAdding: 0)
        let query = HKAnchoredObjectQuery(
            type: sampleType,
            predicate: nil,
            anchor: anchor,
            limit: HKObjectQueryNoLimit
        ) { _, samples, deletedObjects, newAnchor, error in
            guard let newAnchor = newAnchor else { return }
            self.saveAnchor(newAnchor, for: sampleType)
            completion(samples ?? [], deletedObjects ?? [], newAnchor)
        }
        
        healthStore.execute(query)
    }
}

1.3 Observer Query (Real-time Changes)

class HealthKitObserver {
    func setupObserverQueries(for types: [HKSampleType], handler: @escaping (HKSampleType) -> Void) {
        for sampleType in types {
            let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { _, completionHandler, error in
                if error == nil {
                    handler(sampleType)
                }
                completionHandler()
            }
            
            healthStore.execute(query)
            
            // Important: Keep strong reference to prevent query from being deallocated
            activeQueries.append(query)
        }
    }
    
    // Call this when background notification arrives
    func backgroundFetch(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        // Re-run anchored queries to detect changes
        // Update snapshots and detect anomalies
        // Persist any findings
        completionHandler(.newData)
    }
}

2. Storage Implementation

HealthProbe uses two storage layers:

  1. Local Archive Store (source of truth)

    • Stores canonical HealthKit samples and all metadata exposed by the API
    • Uses one schema for all selected data types, so workouts, samples, sources, devices, and metadata can be related later
    • Maintains firstSeen, lastSeen, lastVerified, strict/semantic/fuzzy fingerprints, and integrity hashes
    • Should be implemented with an explicit local database/archive format (not SwiftData model graphs for millions of samples)
  2. SwiftData UI Store (derived/cache layer)

    • Stores settings, logs, import/check history, anomaly summaries, and precomputed values used by charts
    • Can be rebuilt from the archive store
    • Must not be treated as the only forensic copy

2.1 SwiftData UI Models

import SwiftData
import Foundation

// MARK: - Core Models

@Model
final class HealthSnapshot {
    /// Unique identifier
    @Attribute(.unique) var id: String = UUID().uuidString
    
    /// When this snapshot was captured
    var capturedAt: Date
    
    /// Sample type (e.g., "HKWorkout", "HKQuantity:HeartRate")
    var sampleType: String
    
    /// Source device (e.g., "iPhone 15 Pro", "Apple Watch")
    var sourceDevice: String
    
    /// Total samples of this type at capture time
    var recordCount: Int
    
    /// MD5 of aggregated sample IDs (for integrity checking)
    var integrityChecksum: String
    
    /// Aggregated counts by source: { "iPhone Health": 1200, "Apple Watch": 450 }
    var sourceDistribution: [String: Int]
    
    /// Metadata
    var iosVersion: String
    var appVersion: String
    
    init(
        capturedAt: Date,
        sampleType: String,
        sourceDevice: String,
        recordCount: Int,
        integrityChecksum: String,
        sourceDistribution: [String: Int],
        iosVersion: String,
        appVersion: String
    ) {
        self.capturedAt = capturedAt
        self.sampleType = sampleType
        self.sourceDevice = sourceDevice
        self.recordCount = recordCount
        self.integrityChecksum = integrityChecksum
        self.sourceDistribution = sourceDistribution
        self.iosVersion = iosVersion
        self.appVersion = appVersion
    }
}

@Model
final class AuditTrailEntry {
    @Attribute(.unique) var id: String = UUID().uuidString
    var timestamp: Date
    var eventType: String  // "snapshot", "sync_event", "anomaly_detected", etc.
    var message: String
    var context: [String: String]  // JSON-serializable context
    
    init(timestamp: Date, eventType: String, message: String, context: [String: String] = [:]) {
        self.timestamp = timestamp
        self.eventType = eventType
        self.message = message
        self.context = context
    }
}

@Model
final class DetectedAnomaly {
    @Attribute(.unique) var id: String = UUID().uuidString
    var detectedAt: Date
    var type: String  // "historical_insertion", "silent_deletion", "duplicate", "divergence"
    var severity: String  // "info", "warning", "critical"
    var sampleType: String
    var summary: String
    var evidence: [String: String]  // Forensic data
    var resolved: Bool = false
    var resolvedAt: Date?
    
    init(
        detectedAt: Date,
        type: String,
        severity: String,
        sampleType: String,
        summary: String,
        evidence: [String: String] = [:]
    ) {
        self.detectedAt = detectedAt
        self.type = type
        self.severity = severity
        self.sampleType = sampleType
        self.summary = summary
        self.evidence = evidence
    }
}

@Model
final class ContextStateChange {
    @Attribute(.unique) var id: String = UUID().uuidString
    var timestamp: Date
    var previousState: String  // "local_only", "icloud_enabled", "icloud_sync_active"
    var newState: String
    var details: String
    
    init(timestamp: Date, previousState: String, newState: String, details: String = "") {
        self.timestamp = timestamp
        self.previousState = previousState
        self.newState = newState
        self.details = details
    }
}

// MARK: - Model Container Setup

func createModelContainer() throws -> ModelContainer {
    let schema = Schema([
        HealthSnapshot.self,
        AuditTrailEntry.self,
        DetectedAnomaly.self,
        ContextStateChange.self,
    ])
    
    let modelConfiguration = ModelConfiguration(
        schema: schema,
        isStoredInMemoryOnly: false,
        cloudKitDatabase: .none  // Local only in MVP
    )
    
    return try ModelContainer(for: schema, configurations: [modelConfiguration])
}

2.2 Local Archive Store Contract

The archive store should expose a small service interface rather than leaking SQL/archive details into UI code:

protocol HealthArchiveStore {
    func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary
    func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws
    func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
    func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
    func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
}

Archive rows should preserve: - HealthKit UUID where exposed - type identifier, start/end date, value, unit - source, source revision, bundle identifier, version/build/product type where available - HKDevice fields exposed by HealthKit - full metadata dictionary as structured data - relationship keys for workouts, events, and related samples where available - fingerprints for matching records across HealthProbe, Apple Health XML exports, and backup database extracts

The MVP implementation is SQLiteHealthArchiveStore, an actor-isolated SQLite archive in Application Support. It is populated from HealthKit anchored-query pages before SwiftData receives derived snapshot/index rows.


3. Anomaly Detection Implementation

class AnomalyDetector {
    private let modelContext: ModelContext
    private let healthKitManager: HealthKitManager
    
    // MARK: - Historical Insertion Detection
    
    func detectHistoricalInsertions(
        newSamples: [HKSample],
        completion: @escaping ([DetectedAnomaly]) -> Void
    ) {
        var anomalies: [DetectedAnomaly] = []
        let now = Date()
        
        for sample in newSamples {
            let ageInDays = Calendar.current.dateComponents([.day], from: sample.startDate, to: now).day ?? 0
            
            // Check if sample is older than 7 days but was just added
            if ageInDays > 7 {
                let anomaly = DetectedAnomaly(
                    detectedAt: now,
                    type: "historical_insertion",
                    severity: "medium",
                    sampleType: sample.sampleType.identifier,
                    summary: "Sample from \(ageInDays) days ago appeared in HealthKit",
                    evidence: [
                        "original_date": ISO8601DateFormatter().string(from: sample.startDate),
                        "age_days": String(ageInDays),
                        "sample_id": sample.uuid.uuidString,
                    ]
                )
                anomalies.append(anomaly)
            }
        }
        
        completion(anomalies)
    }
    
    // MARK: - Silent Deletion Detection
    
    func detectSilentDeletions(
        previousSnapshot: HealthSnapshot,
        currentSnapshot: HealthSnapshot,
        completion: @escaping ([DetectedAnomaly]) -> Void
    ) {
        var anomalies: [DetectedAnomaly] = []
        
        let previousCount = previousSnapshot.recordCount
        let currentCount = currentSnapshot.recordCount
        let loss = previousCount - currentCount
        
        if loss > 0 {
            let lossPercent = Double(loss) / Double(previousCount) * 100
            let severity = lossPercent > 10 ? "critical" : lossPercent > 5 ? "warning" : "info"
            
            let anomaly = DetectedAnomaly(
                detectedAt: Date(),
                type: "silent_deletion",
                severity: severity,
                sampleType: previousSnapshot.sampleType,
                summary: "\(loss) samples missing (\(String(format: "%.1f", lossPercent))%)",
                evidence: [
                    "previous_count": String(previousCount),
                    "current_count": String(currentCount),
                    "loss_count": String(loss),
                    "loss_percent": String(format: "%.1f", lossPercent),
                    "time_gap": String(describing: Date().timeIntervalSince(previousSnapshot.capturedAt)),
                ]
            )
            anomalies.append(anomaly)
        }
        
        completion(anomalies)
    }
    
    // MARK: - Duplicate Detection
    
    func detectDuplicates(
        samples: [HKSample],
        completion: @escaping ([DetectedAnomaly]) -> Void
    ) {
        var anomalies: [DetectedAnomaly] = []
        var fingerprints: [String: [HKSample]] = [:]
        
        // Group by fingerprint
        for sample in samples {
            let fingerprint = createFingerprint(for: sample)
            fingerprints[fingerprint, default: []].append(sample)
        }
        
        // Find duplicates
        for (fingerprint, dupes) in fingerprints where dupes.count > 1 {
            let anomaly = DetectedAnomaly(
                detectedAt: Date(),
                type: "duplicate",
                severity: "low",
                sampleType: dupes[0].sampleType.identifier,
                summary: "\(dupes.count) duplicate records found",
                evidence: [
                    "fingerprint": fingerprint,
                    "count": String(dupes.count),
                ]
            )
            anomalies.append(anomaly)
        }
        
        completion(anomalies)
    }
    
    // MARK: - Divergence Detection
    
    func detectDivergence(
        currentTrend: [Date: Double],
        historicalBaseline: [Date: Double],
        completion: @escaping ([DetectedAnomaly]) -> Void
    ) {
        // Calculate standard deviations
        let baselineStdDev = standardDeviation(values: Array(historicalBaseline.values))
        let currentStdDev = standardDeviation(values: Array(currentTrend.values))
        
        if currentStdDev > baselineStdDev * 2.0 {
            let anomaly = DetectedAnomaly(
                detectedAt: Date(),
                type: "divergence",
                severity: "medium",
                sampleType: "aggregated_metric",
                summary: "Unusual trend detected (σ increased \(currentStdDev / baselineStdDev)x)",
                evidence: [
                    "baseline_stddev": String(format: "%.2f", baselineStdDev),
                    "current_stddev": String(format: "%.2f", currentStdDev),
                    "ratio": String(format: "%.2f", currentStdDev / baselineStdDev),
                ]
            )
            completion([anomaly])
        } else {
            completion([])
        }
    }
    
    // MARK: - Helpers
    
    private func createFingerprint(for sample: HKSample) -> String {
        let formatter = ISO8601DateFormatter()
        let startStr = formatter.string(from: sample.startDate)
        let endStr = formatter.string(from: sample.endDate)
        let type = sample.sampleType.identifier
        let source = sample.sourceRevision.source.name
        
        return "\(type)|\(startStr)|\(endStr)|\(source)".addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
    }
    
    private func standardDeviation(values: [Double]) -> Double {
        let mean = values.reduce(0, +) / Double(values.count)
        let squaredDiffs = values.map { pow($0 - mean, 2) }
        let variance = squaredDiffs.reduce(0, +) / Double(values.count)
        return sqrt(variance)
    }
}

4. Context Monitoring (Background Thread)

HealthProbe does not sync its own database through iCloud/CloudKit. This service only logs Health/iCloud state as context for later forensic correlation.

class ContextMonitor {
    private let modelContext: ModelContext
    private let queue = DispatchQueue(label: "com.healthprobe.sync-monitor", qos: .background)
    
    private var previousHealthCloudState: String = "unknown"
    
    func startMonitoring() {
        queue.async {
            self.monitorContext()
        }
    }
    
    private func monitorContext() {
        // Check iCloud state
        let iCloudToken = FileManager.default.ubiquityIdentityToken
        let currentState = iCloudToken != nil ? "icloud_enabled" : "local_only"
        
        if currentState != previousHealthCloudState {
            logContextChange(from: previousHealthCloudState, to: currentState)
            previousHealthCloudState = currentState
            
            // Schedule archive verification on state change
            DispatchQueue.main.async {
                NotificationCenter.default.post(name: NSNotification.Name("HealthContextChanged"), object: nil)
            }
        }
    }
    
    private func logContextChange(from: String, to: String) {
        let change = ContextStateChange(
            timestamp: Date(),
            previousState: from,
            newState: to,
            details: "iCloud state changed"
        )
        
        do {
            modelContext.insert(change)
            try modelContext.save()
            
            let auditEntry = AuditTrailEntry(
                timestamp: Date(),
                eventType: "health_context_change",
                message: "Health cloud context: \(from) → \(to)",
                context: ["previous": from, "current": to]
            )
            modelContext.insert(auditEntry)
            try modelContext.save()
        } catch {
            print("Error logging context change: \(error)")
        }
    }
}

5. Integration into App Lifecycle

@main
struct HealthProbeApp: App {
    @StateObject private var healthKitManager = HealthKitManager.shared
    @StateObject private var contextMonitor: ContextMonitor
    let modelContainer: ModelContainer
    
    init() {
        do {
            modelContainer = try createModelContainer()
            let context = ModelContext(modelContainer)
            _contextMonitor = StateObject(wrappedValue: ContextMonitor(modelContext: context))
        } catch {
            fatalError("Could not initialize model container: \(error)")
        }
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(modelContainer)
                .onAppear {
                    // Request HealthKit permissions
                    healthKitManager.requestAuthorization { success, error in
                        if success {
                            // Start context monitoring and archive capture
                            contextMonitor.startMonitoring()
                            captureInitialSnapshot()
                        }
                    }
                }
                .onReceive(Timer.publish(every: 3600).autoconnect()) { _ in
                    // Periodic check every hour
                    refreshHealthData()
                }
        }
    }
    
    private func captureInitialSnapshot() {
        // Implement snapshot capture
    }
    
    private func refreshHealthData() {
        // Implement periodic refresh
    }
}

6. Testing Strategy

Unit Tests

class AnomalyDetectorTests: XCTestCase {
    var detector: AnomalyDetector!
    
    override func setUp() {
        super.setUp()
        detector = AnomalyDetector(...)
    }
    
    func testDetectsHistoricalInsertion() {
        // Create sample from 30 days ago
        // Assert: anomaly detected
    }
    
    func testDetectsSilentDeletion() {
        // Create two snapshots, second has fewer records
        // Assert: anomaly detected with correct loss percentage
    }
}

Integration Tests

  • ✅ HealthKit query performance (anchor efficiency)
  • ✅ Local archive persistence and recovery
  • ✅ SwiftData cache rebuild from archive
  • ✅ Background context monitoring accuracy
  • ✅ Anomaly detection on real HealthKit data

7. Performance Considerations

Operation Target Notes
Anchored query < 5 sec Background, user perceives delay > 2s
Anomaly detection < 2 sec Should not block UI
SwiftData cache update < 1 sec Can run on main thread only after archive work completes
Archive write Background Stream large imports; never build full high-frequency datasets in memory
Background check < 30 sec iOS allows 30 min for background fetch

8. Deployment Checklist

  • [ ] HealthKit read permissions declared in Info.plist
  • [ ] Background Modes enabled ("Background Fetch")
  • [ ] SwiftData model migrations tested
  • [ ] Local archive schema migrations tested
  • [ ] Privacy Policy updated (what data is collected)
  • [ ] Accessibility review (VoiceOver, Dynamic Type)

HealthProbe Implementation Guide v1.0 — 2026-05-01