Showing 7 changed files with 606 additions and 8 deletions
+7 -0
AGENTS.md
@@ -133,6 +133,13 @@ final class TypeDistributionBin {
133 133
 // in the local archive store, in one schema that can preserve relationships across
134 134
 // data types, sources, devices, workouts, and metadata.
135 135
 
136
+// Interface updated 2026-05-18 — see AGENTS.md
137
+// Services/Protocols/HealthArchiveStore.swift defines the local archive boundary.
138
+// SQLiteHealthArchiveStore is the current implementation. HealthKit anchored-query
139
+// pages must be written to this archive before SwiftData UI/cache rows are saved.
140
+// Deletions are recorded by sampleUUIDHash because HKDeletedObject exposes UUIDs,
141
+// not complete sample payloads.
142
+
136 143
 // Interface updated 2026-05-17 — see AGENTS.md
137 144
 // Models/TypeCount.detailCacheData stores precomputed detail data for the current
138 145
 // TypeCount compared with the immediately previous snapshot on the same device.
+6 -4
HealthProbe/Doc/Implementation Guide.md
@@ -281,11 +281,11 @@ The archive store should expose a small service interface rather than leaking SQ
281 281
 
282 282
 ```swift
283 283
 protocol HealthArchiveStore {
284
-    func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws
284
+    func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary
285 285
     func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws
286
-    func recordDisappearance(fingerprint: String, observedMissingAt: Date) async throws
287
-    func records(for reportID: String) async throws -> [ArchivedHealthRecord]
288
-    func exportReport(_ reportID: String) async throws -> URL
286
+    func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
287
+    func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
288
+    func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
289 289
 }
290 290
 ```
291 291
 
@@ -298,6 +298,8 @@ Archive rows should preserve:
298 298
 - relationship keys for workouts, events, and related samples where available
299 299
 - fingerprints for matching records across HealthProbe, Apple Health XML exports, and backup database extracts
300 300
 
301
+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.
302
+
301 303
 ---
302 304
 
303 305
 ## 3. Anomaly Detection Implementation
+24 -0
HealthProbe/Services/HashService.swift
@@ -83,6 +83,30 @@ enum HashService {
83 83
         return digest.map { String(format: "%02x", $0) }.joined()
84 84
     }
85 85
 
86
+    static func archiveSemanticFingerprint(
87
+        typeIdentifier: String,
88
+        startDate: Date,
89
+        endDate: Date,
90
+        value: Double?,
91
+        unit: String?,
92
+        categoryValue: Int?,
93
+        workoutActivityType: UInt?,
94
+        sourceBundleIdentifier: String?
95
+    ) -> String {
96
+        let input = [
97
+            typeIdentifier,
98
+            iso8601Formatter.string(from: startDate),
99
+            iso8601Formatter.string(from: endDate),
100
+            value.map { String(format: "%.12g", $0) } ?? "",
101
+            unit ?? "",
102
+            categoryValue.map(String.init) ?? "",
103
+            workoutActivityType.map(String.init) ?? "",
104
+            sourceBundleIdentifier ?? ""
105
+        ].joined(separator: "|")
106
+        let digest = SHA256.hash(data: Data(input.utf8))
107
+        return digest.map { String(format: "%02x", $0) }.joined()
108
+    }
109
+
86 110
     // Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes.
87 111
     // Filter criterion: quality == .complete; do not use contentHash != "" as a proxy.
88 112
     // A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded.
+25 -0
HealthProbe/Services/HealthKitService.swift
@@ -38,6 +38,11 @@ extension Array where Element == MonitoredType {
38 38
 final class HealthKitService {
39 39
     static let shared = HealthKitService()
40 40
     let store = HKHealthStore()
41
+    private let archiveStore: HealthArchiveStore
42
+
43
+    private init(archiveStore: HealthArchiveStore = SQLiteHealthArchiveStore.shared) {
44
+        self.archiveStore = archiveStore
45
+    }
41 46
 
42 47
     static let allTypes: [MonitoredType] = buildAllTypes()
43 48
 
@@ -854,6 +859,7 @@ final class HealthKitService {
854 859
                     anchor: anchor
855 860
                 )
856 861
             }
862
+            try await archivePage(page, sampleType: sampleType)
857 863
             anchor = page.anchor
858 864
 
859 865
             if page.samples.isEmpty, page.deletedObjects.isEmpty,
@@ -863,6 +869,7 @@ final class HealthKitService {
863 869
                     earliestDate: earliestDate,
864 870
                     latestDate: latestDate
865 871
                ) {
872
+                try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date())
866 873
                 progress?.updateBlockProgress(
867 874
                     typeIdentifier,
868 875
                     detail: "No HealthKit delta",
@@ -934,6 +941,7 @@ final class HealthKitService {
934 941
                     anchor: anchor
935 942
                 )
936 943
             }
944
+            try await archivePage(page, sampleType: sampleType)
937 945
             anchor = page.anchor
938 946
 
939 947
             applyDistributionPage(page, sampleType: sampleType, to: &recordMap)
@@ -955,6 +963,8 @@ final class HealthKitService {
955 963
             )
956 964
         }
957 965
 
966
+        try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date())
967
+
958 968
         let sortedKeys = recordMap.keys.sorted {
959 969
             guard let left = recordMap[$0],
960 970
                   let right = recordMap[$1] else {
@@ -1068,6 +1078,7 @@ final class HealthKitService {
1068 1078
                     anchor: anchor
1069 1079
                 )
1070 1080
             }
1081
+            try await archivePage(page, sampleType: sampleType)
1071 1082
             anchor = page.anchor
1072 1083
 
1073 1084
             for sample in page.samples {
@@ -1111,6 +1122,8 @@ final class HealthKitService {
1111 1122
             )
1112 1123
         )
1113 1124
 
1125
+        try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date())
1126
+
1114 1127
         guard recordCount > 0 || anchor != nil else {
1115 1128
             return SampleDistribution(
1116 1129
                 totalCount: 0,
@@ -1253,6 +1266,18 @@ final class HealthKitService {
1253 1266
         }
1254 1267
     }
1255 1268
 
1269
+    private func archivePage(_ page: SampleDistributionPage, sampleType: HKSampleType) async throws {
1270
+        let observedAt = Date()
1271
+        _ = try await archiveStore.upsertSamples(page.samples, observedAt: observedAt)
1272
+        for deletedObject in page.deletedObjects {
1273
+            try await archiveStore.recordDisappearance(
1274
+                sampleUUIDHash: HashService.sampleUUIDHash(deletedObject.uuid.uuidString),
1275
+                sampleTypeIdentifier: sampleType.identifier,
1276
+                observedMissingAt: observedAt
1277
+            )
1278
+        }
1279
+    }
1280
+
1256 1281
     private static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
1257 1282
         try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
1258 1283
     }
+2 -2
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -5,7 +5,7 @@ import HealthKit
5 5
 protocol HealthArchiveStore {
6 6
     func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary
7 7
     func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws
8
-    func recordDisappearance(fingerprint: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
8
+    func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws
9 9
     func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
10 10
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
11 11
 }
@@ -28,7 +28,7 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
28 28
     let includedFingerprints: Set<String>
29 29
 }
30 30
 
31
-struct ArchivedHealthRecord: Identifiable, Equatable, Sendable {
31
+struct ArchivedHealthRecord: Identifiable, Equatable, Sendable, Encodable {
32 32
     let id: String
33 33
     let sampleTypeIdentifier: String
34 34
     let strictFingerprint: String
+538 -0
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -0,0 +1,538 @@
1
+import Foundation
2
+import HealthKit
3
+import SQLite3
4
+
5
+private enum SQLiteHealthArchiveStoreError: Error {
6
+    case openFailed(String)
7
+    case prepareFailed(String)
8
+    case stepFailed(String)
9
+    case exportEncodingFailed
10
+}
11
+
12
+// Interface updated 2026-05-18 — see AGENTS.md
13
+actor SQLiteHealthArchiveStore: HealthArchiveStore {
14
+    static let shared = SQLiteHealthArchiveStore()
15
+
16
+    private let databaseURL: URL
17
+    private var didPrepareSchema = false
18
+
19
+    init(databaseURL: URL? = nil) {
20
+        let supportURL = URL.applicationSupportDirectory
21
+        self.databaseURL = databaseURL ?? supportURL.appending(path: "HealthProbeArchive.sqlite")
22
+    }
23
+
24
+    func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
25
+        guard !samples.isEmpty else {
26
+            return HealthArchiveWriteSummary(insertedCount: 0, updatedCount: 0, unchangedCount: 0)
27
+        }
28
+
29
+        let db = try openDatabase()
30
+        defer { sqlite3_close(db) }
31
+        try prepareSchemaIfNeeded(db)
32
+        try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
33
+        do {
34
+            let summary = try upsertSamples(samples, observedAt: observedAt, db: db)
35
+            try execute("COMMIT", db: db)
36
+            return summary
37
+        } catch {
38
+            try? execute("ROLLBACK", db: db)
39
+            throw error
40
+        }
41
+    }
42
+
43
+    func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws {
44
+        let db = try openDatabase()
45
+        defer { sqlite3_close(db) }
46
+        try prepareSchemaIfNeeded(db)
47
+
48
+        let sql = """
49
+        UPDATE archive_samples
50
+        SET last_verified_at = ?, last_seen_at = COALESCE(last_seen_at, ?)
51
+        WHERE type_identifier = ? AND disappeared_at IS NULL
52
+        """
53
+        try withStatement(sql, db: db) { statement in
54
+            sqlite3_bind_double(statement, 1, verifiedAt.timeIntervalSinceReferenceDate)
55
+            sqlite3_bind_double(statement, 2, verifiedAt.timeIntervalSinceReferenceDate)
56
+            bindText(sampleType.identifier, to: 3, in: statement)
57
+            guard sqlite3_step(statement) == SQLITE_DONE else {
58
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
59
+            }
60
+        }
61
+    }
62
+
63
+    func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws {
64
+        let db = try openDatabase()
65
+        defer { sqlite3_close(db) }
66
+        try prepareSchemaIfNeeded(db)
67
+
68
+        let sql = """
69
+        UPDATE archive_samples
70
+        SET disappeared_at = ?, last_verified_at = ?
71
+        WHERE sample_uuid_hash = ? AND type_identifier = ?
72
+        """
73
+        try withStatement(sql, db: db) { statement in
74
+            sqlite3_bind_double(statement, 1, observedMissingAt.timeIntervalSinceReferenceDate)
75
+            sqlite3_bind_double(statement, 2, observedMissingAt.timeIntervalSinceReferenceDate)
76
+            bindText(sampleUUIDHash, to: 3, in: statement)
77
+            bindText(sampleTypeIdentifier, to: 4, in: statement)
78
+            guard sqlite3_step(statement) == SQLITE_DONE else {
79
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
80
+            }
81
+        }
82
+    }
83
+
84
+    func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord] {
85
+        let db = try openDatabase()
86
+        defer { sqlite3_close(db) }
87
+        try prepareSchemaIfNeeded(db)
88
+
89
+        var clauses: [String] = []
90
+        if request.sampleTypeIdentifier != nil {
91
+            clauses.append("type_identifier = ?")
92
+        }
93
+        if !request.fingerprints.isEmpty {
94
+            clauses.append("strict_fingerprint IN (\(Array(repeating: "?", count: request.fingerprints.count).joined(separator: ",")))")
95
+        }
96
+        let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))"
97
+        let limitClause = request.limit.map { "LIMIT \(max($0, 0))" } ?? ""
98
+        let sql = """
99
+        SELECT sample_uuid_hash, type_identifier, strict_fingerprint, semantic_fingerprint,
100
+               start_date, end_date, first_seen_at, last_seen_at, last_verified_at, disappeared_at
101
+        FROM archive_samples
102
+        \(whereClause)
103
+        ORDER BY start_date ASC, strict_fingerprint ASC
104
+        \(limitClause)
105
+        """
106
+
107
+        return try withStatement(sql, db: db) { statement in
108
+            var index: Int32 = 1
109
+            if let typeIdentifier = request.sampleTypeIdentifier {
110
+                bindText(typeIdentifier, to: index, in: statement)
111
+                index += 1
112
+            }
113
+            for fingerprint in request.fingerprints.sorted() {
114
+                bindText(fingerprint, to: index, in: statement)
115
+                index += 1
116
+            }
117
+
118
+            var records: [ArchivedHealthRecord] = []
119
+            while sqlite3_step(statement) == SQLITE_ROW {
120
+                records.append(ArchivedHealthRecord(
121
+                    id: columnText(statement, 0) ?? "",
122
+                    sampleTypeIdentifier: columnText(statement, 1) ?? "",
123
+                    strictFingerprint: columnText(statement, 2) ?? "",
124
+                    semanticFingerprint: columnText(statement, 3),
125
+                    healthKitUUIDHash: columnText(statement, 0),
126
+                    startDate: columnDate(statement, 4) ?? Date(timeIntervalSinceReferenceDate: 0),
127
+                    endDate: columnDate(statement, 5) ?? Date(timeIntervalSinceReferenceDate: 0),
128
+                    firstSeenAt: columnDate(statement, 6) ?? Date(timeIntervalSinceReferenceDate: 0),
129
+                    lastSeenAt: columnDate(statement, 7),
130
+                    lastVerifiedAt: columnDate(statement, 8),
131
+                    disappearedAt: columnDate(statement, 9)
132
+                ))
133
+            }
134
+            return records
135
+        }
136
+    }
137
+
138
+    func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL {
139
+        let records = try await records(for: HealthArchiveRecordRequest(
140
+            sampleTypeIdentifier: nil,
141
+            fingerprints: request.includedFingerprints,
142
+            limit: nil
143
+        ))
144
+        let payload = HealthArchiveReportPayload(
145
+            reportID: request.reportID,
146
+            title: request.title,
147
+            exportedAt: Date(),
148
+            records: records
149
+        )
150
+        let data = try JSONEncoder.healthArchive.encode(payload)
151
+        let exportURL = URL.temporaryDirectory
152
+            .appending(path: "HealthProbe-\(request.reportID.uuidString)")
153
+            .appendingPathExtension("json")
154
+        try data.write(to: exportURL, options: [.atomic])
155
+        return exportURL
156
+    }
157
+
158
+    private func openDatabase() throws -> OpaquePointer? {
159
+        try FileManager.default.createDirectory(
160
+            at: databaseURL.deletingLastPathComponent(),
161
+            withIntermediateDirectories: true
162
+        )
163
+        var db: OpaquePointer?
164
+        guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
165
+            let message = db.map(lastErrorMessage) ?? "unable to open archive database"
166
+            sqlite3_close(db)
167
+            throw SQLiteHealthArchiveStoreError.openFailed(message)
168
+        }
169
+        return db
170
+    }
171
+
172
+    private func prepareSchemaIfNeeded(_ db: OpaquePointer?) throws {
173
+        guard !didPrepareSchema else { return }
174
+        try execute("PRAGMA journal_mode = WAL", db: db)
175
+        try execute("PRAGMA foreign_keys = ON", db: db)
176
+        try execute("""
177
+        CREATE TABLE IF NOT EXISTS archive_samples (
178
+            sample_uuid_hash TEXT PRIMARY KEY NOT NULL,
179
+            type_identifier TEXT NOT NULL,
180
+            strict_fingerprint TEXT NOT NULL,
181
+            semantic_fingerprint TEXT,
182
+            start_date REAL NOT NULL,
183
+            end_date REAL NOT NULL,
184
+            first_seen_at REAL NOT NULL,
185
+            last_seen_at REAL,
186
+            last_verified_at REAL,
187
+            disappeared_at REAL,
188
+            observed_count INTEGER NOT NULL DEFAULT 1,
189
+            value_kind TEXT,
190
+            value REAL,
191
+            unit TEXT,
192
+            category_value INTEGER,
193
+            workout_activity_type INTEGER,
194
+            duration_seconds REAL,
195
+            source_name TEXT,
196
+            source_bundle_identifier TEXT,
197
+            source_product_type TEXT,
198
+            source_version TEXT,
199
+            source_operating_system_version TEXT,
200
+            device_name TEXT,
201
+            device_manufacturer TEXT,
202
+            device_model TEXT,
203
+            device_hardware_version TEXT,
204
+            device_firmware_version TEXT,
205
+            device_software_version TEXT,
206
+            device_local_identifier TEXT,
207
+            device_udi_device_identifier TEXT,
208
+            metadata_json TEXT,
209
+            archived_at REAL NOT NULL
210
+        )
211
+        """, db: db)
212
+        try execute("CREATE INDEX IF NOT EXISTS idx_archive_samples_type_date ON archive_samples(type_identifier, start_date)", db: db)
213
+        try execute("CREATE INDEX IF NOT EXISTS idx_archive_samples_strict_fingerprint ON archive_samples(strict_fingerprint)", db: db)
214
+        didPrepareSchema = true
215
+    }
216
+
217
+    private func upsertSamples(_ samples: [HKSample], observedAt: Date, db: OpaquePointer?) throws -> HealthArchiveWriteSummary {
218
+        let sql = """
219
+        INSERT INTO archive_samples (
220
+            sample_uuid_hash, type_identifier, strict_fingerprint, semantic_fingerprint,
221
+            start_date, end_date, first_seen_at, last_seen_at, last_verified_at,
222
+            disappeared_at, observed_count, value_kind, value, unit, category_value,
223
+            workout_activity_type, duration_seconds, source_name, source_bundle_identifier,
224
+            source_product_type, source_version, source_operating_system_version,
225
+            device_name, device_manufacturer, device_model, device_hardware_version,
226
+            device_firmware_version, device_software_version, device_local_identifier,
227
+            device_udi_device_identifier, metadata_json, archived_at
228
+        ) VALUES (
229
+            ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
230
+        )
231
+        ON CONFLICT(sample_uuid_hash) DO UPDATE SET
232
+            strict_fingerprint = excluded.strict_fingerprint,
233
+            semantic_fingerprint = excluded.semantic_fingerprint,
234
+            start_date = excluded.start_date,
235
+            end_date = excluded.end_date,
236
+            last_seen_at = excluded.last_seen_at,
237
+            last_verified_at = excluded.last_verified_at,
238
+            disappeared_at = NULL,
239
+            observed_count = archive_samples.observed_count + 1,
240
+            value_kind = excluded.value_kind,
241
+            value = excluded.value,
242
+            unit = excluded.unit,
243
+            category_value = excluded.category_value,
244
+            workout_activity_type = excluded.workout_activity_type,
245
+            duration_seconds = excluded.duration_seconds,
246
+            source_name = excluded.source_name,
247
+            source_bundle_identifier = excluded.source_bundle_identifier,
248
+            source_product_type = excluded.source_product_type,
249
+            source_version = excluded.source_version,
250
+            source_operating_system_version = excluded.source_operating_system_version,
251
+            device_name = excluded.device_name,
252
+            device_manufacturer = excluded.device_manufacturer,
253
+            device_model = excluded.device_model,
254
+            device_hardware_version = excluded.device_hardware_version,
255
+            device_firmware_version = excluded.device_firmware_version,
256
+            device_software_version = excluded.device_software_version,
257
+            device_local_identifier = excluded.device_local_identifier,
258
+            device_udi_device_identifier = excluded.device_udi_device_identifier,
259
+            metadata_json = excluded.metadata_json,
260
+            archived_at = excluded.archived_at
261
+        """
262
+
263
+        return try withStatement(sql, db: db) { statement in
264
+            var inserted = 0
265
+            var updated = 0
266
+            for sample in samples {
267
+                sqlite3_reset(statement)
268
+                sqlite3_clear_bindings(statement)
269
+                let row = ArchiveSampleRow(sample: sample, observedAt: observedAt)
270
+                bind(row, to: statement)
271
+                guard sqlite3_step(statement) == SQLITE_DONE else {
272
+                    throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
273
+                }
274
+                if sqlite3_changes(db) == 1 {
275
+                    inserted += 1
276
+                } else {
277
+                    updated += 1
278
+                }
279
+            }
280
+            return HealthArchiveWriteSummary(
281
+                insertedCount: inserted,
282
+                updatedCount: updated,
283
+                unchangedCount: max(0, samples.count - inserted - updated)
284
+            )
285
+        }
286
+    }
287
+
288
+    private func execute(_ sql: String, db: OpaquePointer?) throws {
289
+        guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else {
290
+            throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
291
+        }
292
+    }
293
+
294
+    private func withStatement<T>(_ sql: String, db: OpaquePointer?, body: (OpaquePointer?) throws -> T) throws -> T {
295
+        var statement: OpaquePointer?
296
+        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
297
+            throw SQLiteHealthArchiveStoreError.prepareFailed(lastErrorMessage(db))
298
+        }
299
+        defer { sqlite3_finalize(statement) }
300
+        return try body(statement)
301
+    }
302
+
303
+    private func bind(_ row: ArchiveSampleRow, to statement: OpaquePointer?) {
304
+        bindText(row.sampleUUIDHash, to: 1, in: statement)
305
+        bindText(row.typeIdentifier, to: 2, in: statement)
306
+        bindText(row.strictFingerprint, to: 3, in: statement)
307
+        bindText(row.semanticFingerprint, to: 4, in: statement)
308
+        sqlite3_bind_double(statement, 5, row.startDate.timeIntervalSinceReferenceDate)
309
+        sqlite3_bind_double(statement, 6, row.endDate.timeIntervalSinceReferenceDate)
310
+        sqlite3_bind_double(statement, 7, row.observedAt.timeIntervalSinceReferenceDate)
311
+        sqlite3_bind_double(statement, 8, row.observedAt.timeIntervalSinceReferenceDate)
312
+        sqlite3_bind_double(statement, 9, row.observedAt.timeIntervalSinceReferenceDate)
313
+        bindText(row.valueKind, to: 10, in: statement)
314
+        bindDouble(row.value, to: 11, in: statement)
315
+        bindText(row.unit, to: 12, in: statement)
316
+        bindInt(row.categoryValue, to: 13, in: statement)
317
+        bindInt(row.workoutActivityType, to: 14, in: statement)
318
+        bindDouble(row.durationSeconds, to: 15, in: statement)
319
+        bindText(row.sourceName, to: 16, in: statement)
320
+        bindText(row.sourceBundleIdentifier, to: 17, in: statement)
321
+        bindText(row.sourceProductType, to: 18, in: statement)
322
+        bindText(row.sourceVersion, to: 19, in: statement)
323
+        bindText(row.sourceOperatingSystemVersion, to: 20, in: statement)
324
+        bindText(row.deviceName, to: 21, in: statement)
325
+        bindText(row.deviceManufacturer, to: 22, in: statement)
326
+        bindText(row.deviceModel, to: 23, in: statement)
327
+        bindText(row.deviceHardwareVersion, to: 24, in: statement)
328
+        bindText(row.deviceFirmwareVersion, to: 25, in: statement)
329
+        bindText(row.deviceSoftwareVersion, to: 26, in: statement)
330
+        bindText(row.deviceLocalIdentifier, to: 27, in: statement)
331
+        bindText(row.deviceUDI, to: 28, in: statement)
332
+        bindText(row.metadataJSON, to: 29, in: statement)
333
+        sqlite3_bind_double(statement, 30, row.observedAt.timeIntervalSinceReferenceDate)
334
+    }
335
+}
336
+
337
+private struct ArchiveSampleRow {
338
+    let sampleUUIDHash: String
339
+    let typeIdentifier: String
340
+    let strictFingerprint: String
341
+    let semanticFingerprint: String
342
+    let startDate: Date
343
+    let endDate: Date
344
+    let observedAt: Date
345
+    let valueKind: String?
346
+    let value: Double?
347
+    let unit: String?
348
+    let categoryValue: Int?
349
+    let workoutActivityType: Int?
350
+    let durationSeconds: Double?
351
+    let sourceName: String?
352
+    let sourceBundleIdentifier: String?
353
+    let sourceProductType: String?
354
+    let sourceVersion: String?
355
+    let sourceOperatingSystemVersion: String?
356
+    let deviceName: String?
357
+    let deviceManufacturer: String?
358
+    let deviceModel: String?
359
+    let deviceHardwareVersion: String?
360
+    let deviceFirmwareVersion: String?
361
+    let deviceSoftwareVersion: String?
362
+    let deviceLocalIdentifier: String?
363
+    let deviceUDI: String?
364
+    let metadataJSON: String?
365
+
366
+    nonisolated init(sample: HKSample, observedAt: Date) {
367
+        let sampleUUID = sample.uuid.uuidString
368
+        let typeIdentifier = sample.sampleType.identifier
369
+        let quantity = ArchiveSampleRow.quantityPayload(sample)
370
+        let category = sample as? HKCategorySample
371
+        let workout = sample as? HKWorkout
372
+        let sourceRevision = sample.sourceRevision
373
+        let device = sample.device
374
+
375
+        self.sampleUUIDHash = HashService.sampleUUIDHash(sampleUUID)
376
+        self.typeIdentifier = typeIdentifier
377
+        self.strictFingerprint = HashService.sampleFingerprint(
378
+            typeIdentifier: typeIdentifier,
379
+            sampleUUID: sampleUUID,
380
+            startDate: sample.startDate,
381
+            endDate: sample.endDate
382
+        )
383
+        self.semanticFingerprint = HashService.archiveSemanticFingerprint(
384
+            typeIdentifier: typeIdentifier,
385
+            startDate: sample.startDate,
386
+            endDate: sample.endDate,
387
+            value: quantity?.value,
388
+            unit: quantity?.unit,
389
+            categoryValue: category?.value,
390
+            workoutActivityType: workout?.workoutActivityType.rawValue,
391
+            sourceBundleIdentifier: sourceRevision.source.bundleIdentifier
392
+        )
393
+        self.startDate = sample.startDate
394
+        self.endDate = sample.endDate
395
+        self.observedAt = observedAt
396
+        self.valueKind = quantity?.kind ?? (category == nil ? (workout == nil ? nil : "workout") : "category")
397
+        self.value = quantity?.value
398
+        self.unit = quantity?.unit
399
+        self.categoryValue = category?.value
400
+        self.workoutActivityType = workout.map { Int($0.workoutActivityType.rawValue) }
401
+        self.durationSeconds = workout?.duration
402
+        self.sourceName = sourceRevision.source.name
403
+        self.sourceBundleIdentifier = sourceRevision.source.bundleIdentifier
404
+        self.sourceProductType = sourceRevision.productType
405
+        self.sourceVersion = sourceRevision.version
406
+        self.sourceOperatingSystemVersion = ArchiveSampleRow.operatingSystemVersionString(sourceRevision.operatingSystemVersion)
407
+        self.deviceName = device?.name
408
+        self.deviceManufacturer = device?.manufacturer
409
+        self.deviceModel = device?.model
410
+        self.deviceHardwareVersion = device?.hardwareVersion
411
+        self.deviceFirmwareVersion = device?.firmwareVersion
412
+        self.deviceSoftwareVersion = device?.softwareVersion
413
+        self.deviceLocalIdentifier = device?.localIdentifier
414
+        self.deviceUDI = device?.udiDeviceIdentifier
415
+        self.metadataJSON = ArchiveSampleRow.metadataJSONString(sample.metadata)
416
+    }
417
+
418
+    nonisolated private static func quantityPayload(_ sample: HKSample) -> (kind: String, value: Double, unit: String)? {
419
+        guard let sample = sample as? HKQuantitySample else { return nil }
420
+        let identifier = sample.quantityType.identifier
421
+        switch identifier {
422
+        case HKQuantityTypeIdentifier.heartRate.rawValue,
423
+             HKQuantityTypeIdentifier.restingHeartRate.rawValue:
424
+            return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
425
+        case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
426
+            return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
427
+        case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
428
+            return ("quantity", sample.quantity.doubleValue(for: .kilocalorie()), "kcal")
429
+        case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue:
430
+            return ("quantity", sample.quantity.doubleValue(for: .meter()), "m")
431
+        case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
432
+            return ("quantity", sample.quantity.doubleValue(for: .minute()), "min")
433
+        case HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue,
434
+             HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue:
435
+            return ("quantity", sample.quantity.doubleValue(for: .decibelAWeightedSoundPressureLevel()), "dBASPL")
436
+        case HKQuantityTypeIdentifier.bodyMass.rawValue:
437
+            return ("quantity", sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)), "kg")
438
+        case HKQuantityTypeIdentifier.vo2Max.rawValue:
439
+            let unit = HKUnit.literUnit(with: .milli)
440
+                .unitDivided(by: HKUnit.gramUnit(with: .kilo))
441
+                .unitDivided(by: .minute())
442
+            return ("quantity", sample.quantity.doubleValue(for: unit), "mL/kg/min")
443
+        default:
444
+            return ("quantity", sample.quantity.doubleValue(for: .count()), "count")
445
+        }
446
+    }
447
+
448
+    nonisolated private static func metadataJSONString(_ metadata: [String: Any]?) -> String? {
449
+        guard let metadata, !metadata.isEmpty else { return nil }
450
+        let sanitized = metadata.mapValues(sanitize)
451
+        guard JSONSerialization.isValidJSONObject(sanitized),
452
+              let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) else {
453
+            return nil
454
+        }
455
+        return String(data: data, encoding: .utf8)
456
+    }
457
+
458
+    nonisolated private static func sanitize(_ value: Any) -> Any {
459
+        switch value {
460
+        case let value as String:
461
+            return value
462
+        case let value as NSNumber:
463
+            return value
464
+        case let value as Date:
465
+            return ISO8601DateFormatter().string(from: value)
466
+        case let value as UUID:
467
+            return value.uuidString
468
+        case let values as [Any]:
469
+            return values.map(sanitize)
470
+        case let values as [String: Any]:
471
+            return values.mapValues(sanitize)
472
+        default:
473
+            return String(describing: value)
474
+        }
475
+    }
476
+
477
+    nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String {
478
+        "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
479
+    }
480
+}
481
+
482
+nonisolated private struct HealthArchiveReportPayload: Encodable {
483
+    let reportID: UUID
484
+    let title: String
485
+    let exportedAt: Date
486
+    let records: [ArchivedHealthRecord]
487
+}
488
+
489
+nonisolated private extension JSONEncoder {
490
+    static var healthArchive: JSONEncoder {
491
+        let encoder = JSONEncoder()
492
+        encoder.dateEncodingStrategy = .iso8601
493
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
494
+        return encoder
495
+    }
496
+}
497
+
498
+nonisolated private func bindText(_ value: String?, to index: Int32, in statement: OpaquePointer?) {
499
+    guard let value else {
500
+        sqlite3_bind_null(statement, index)
501
+        return
502
+    }
503
+    sqlite3_bind_text(statement, index, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
504
+}
505
+
506
+nonisolated private func bindDouble(_ value: Double?, to index: Int32, in statement: OpaquePointer?) {
507
+    guard let value else {
508
+        sqlite3_bind_null(statement, index)
509
+        return
510
+    }
511
+    sqlite3_bind_double(statement, index, value)
512
+}
513
+
514
+nonisolated private func bindInt(_ value: Int?, to index: Int32, in statement: OpaquePointer?) {
515
+    guard let value else {
516
+        sqlite3_bind_null(statement, index)
517
+        return
518
+    }
519
+    sqlite3_bind_int64(statement, index, sqlite3_int64(value))
520
+}
521
+
522
+nonisolated private func columnText(_ statement: OpaquePointer?, _ index: Int32) -> String? {
523
+    guard sqlite3_column_type(statement, index) != SQLITE_NULL,
524
+          let pointer = sqlite3_column_text(statement, index) else {
525
+        return nil
526
+    }
527
+    return String(cString: pointer)
528
+}
529
+
530
+nonisolated private func columnDate(_ statement: OpaquePointer?, _ index: Int32) -> Date? {
531
+    guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
532
+    return Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, index))
533
+}
534
+
535
+nonisolated private func lastErrorMessage(_ db: OpaquePointer?) -> String {
536
+    guard let message = sqlite3_errmsg(db) else { return "unknown SQLite error" }
537
+    return String(cString: message)
538
+}
+4 -2
IMPLEMENTATION_STATUS.md
@@ -95,6 +95,8 @@ HealthProbe's comprehensive snapshot + delta system has been implemented accordi
95 95
   - uiCacheConfig: HealthSnapshot, TypeCount, YearlyCount, SnapshotDelta, TypeDelta, AnomalyRecord (derived local UI/index data)
96 96
   - localConfig: OperationLog, DeviceProfile, MetricTimeoutProfile (local-only settings and operation metadata)
97 97
 - Added `HealthArchiveStore` protocol for the single local archive store source of truth
98
+- Added `SQLiteHealthArchiveStore`: actor-isolated SQLite archive with WAL, per-sample upsert, disappearance marking, verification timestamps, semantic fingerprints, metadata JSON, and scoped JSON report export
99
+- HealthKit anchored-query pages now archive samples/deletions before SwiftData snapshot/index rows are built
98 100
 - Schema migration recovery: removes legacy SwiftData stores and retries once on failure
99 101
 
100 102
 ### UI (Step 13)
@@ -212,7 +214,7 @@ These tests should be run to ensure all backend functionality is correct:
212 214
 
213 215
 1. **Hash** covers only count + date range, not distribution (silentReplacement is best-effort)
214 216
 2. **YearlyCount** precision requires daily bucket granularity (noted if isApproximate)
215
-3. **Local archive store implementation is still pending** (protocol boundary exists, SQLite/archive schema still needed)
217
+3. **Archive query/report UI is still pending** (store exists, UI still mostly reads SwiftData cache)
216 218
 4. **No automatic cross-device reconstruction**; cross-device analysis is future macOS/report work
217 219
 
218 220
 ## Next Steps
@@ -221,7 +223,7 @@ These tests should be run to ensure all backend functionality is correct:
221 223
 1. Run all 32 verification checks against real HealthKit data
222 224
 2. Create unit tests for delta merge, reason priority, anomaly detection
223 225
 3. Test observer callback debounce with real HKObserverQuery
224
-4. Implement the local archive store behind `HealthArchiveStore`
226
+4. Add archive status/report UI backed by `HealthArchiveStore`
225 227
 
226 228
 ### Post-MVP
227 229
 1. Integrate actual BGTask expiration guard for observer snapshots (capture partial results)