Showing 7 changed files with 633 additions and 49 deletions
+5 -7
HealthProbe/Services/AnomalyDetector.swift
@@ -18,12 +18,10 @@ enum AnomalyDetector {
18 18
         currentTypeCounts: [String: TypeCount],
19 19
         previousTypeCounts: [String: TypeCount]
20 20
     ) -> DetectionResult {
21
-        // Quality gate — suppresses ALL detection if either snapshot is not complete.
22
-        // This also covers deletion anomalies and the first authorization after full deny:
23
-        // if previous.snapshotQuality == .unauthorized, detection returns [] immediately.
24
-        // No additional check is needed; the quality gate is the complete suppression mechanism.
25
-        guard previous.snapshotQuality == SnapshotQuality.complete,
26
-              current.snapshotQuality == SnapshotQuality.complete else {
21
+                // Quality gate: current snapshot must be complete.
22
+                // Previous may be partial due known unavailable metrics; per-type quality guards
23
+                // below still prevent analysis on impaired comparisons.
24
+                guard current.snapshotQuality == SnapshotQuality.complete else {
27 25
             return DetectionResult(records: [])
28 26
         }
29 27
 
@@ -98,7 +96,7 @@ enum AnomalyDetector {
98 96
                     type: .deletion,
99 97
                     severity: severity,
100 98
                     typeID: typeID,
101
-                    message: "Deletion detected for \(typeDelta.displayName): \(countDelta) records"
99
+                    message: "Records disappeared for \(typeDelta.displayName): \(countDelta) records. Verify Health read authorization first; if access was intentionally revoked, confirm that decision. Otherwise, treat this as possible data deletion."
102 100
                 )
103 101
                 records.append(record)
104 102
             }
+66 -1
HealthProbe/Services/DeltaService.swift
@@ -39,10 +39,19 @@ enum DeltaService {
39 39
         for typeID in allTypeIDs {
40 40
             let prev = prevByID[typeID]
41 41
             let curr = currByID[typeID]
42
-            let td = buildTypeDelta(
42
+
43
+            let effectivePrev = historicalBaselinePreviousTypeCount(
43 44
                 typeID: typeID,
44 45
                 prev: prev,
45 46
                 curr: curr,
47
+                previousSnapshot: previous,
48
+                context: context
49
+            ) ?? prev
50
+
51
+            let td = buildTypeDelta(
52
+                typeID: typeID,
53
+                prev: effectivePrev,
54
+                curr: curr,
46 55
                 previous: previous,
47 56
                 current: current
48 57
             )
@@ -174,6 +183,62 @@ enum DeltaService {
174 183
         return td
175 184
     }
176 185
 
186
+    private static func historicalBaselinePreviousTypeCount(
187
+        typeID: String,
188
+        prev: TypeCount?,
189
+        curr: TypeCount?,
190
+        previousSnapshot: HealthSnapshot,
191
+        context: ModelContext
192
+    ) -> TypeCount? {
193
+        guard let prev,
194
+              let curr,
195
+              prev.quality == .unauthorized,
196
+              curr.quality == .complete,
197
+              curr.count > 0 else {
198
+            return nil
199
+        }
200
+
201
+        return findLastCompleteValuedTypeCount(
202
+            typeID: typeID,
203
+            before: previousSnapshot,
204
+            context: context
205
+        )
206
+    }
207
+
208
+    private static func findLastCompleteValuedTypeCount(
209
+        typeID: String,
210
+        before snapshot: HealthSnapshot,
211
+        context: ModelContext
212
+    ) -> TypeCount? {
213
+        var visited: Set<UUID> = []
214
+        var cursorID = snapshot.previousSnapshotID
215
+
216
+        while let snapshotID = cursorID, !visited.contains(snapshotID) {
217
+            visited.insert(snapshotID)
218
+
219
+            guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
220
+                break
221
+            }
222
+
223
+            if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
224
+               candidate.quality == .complete,
225
+               candidate.count > 0 {
226
+                return candidate
227
+            }
228
+
229
+            cursorID = historicalSnapshot.previousSnapshotID
230
+        }
231
+
232
+        return nil
233
+    }
234
+
235
+    private static func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
236
+        let descriptor = FetchDescriptor<HealthSnapshot>(
237
+            predicate: #Predicate<HealthSnapshot> { $0.id == id }
238
+        )
239
+        return try? context.fetch(descriptor).first
240
+    }
241
+
177 242
     private static func assignReason(
178 243
         prevQuality: SnapshotQuality?,
179 244
         currQuality: SnapshotQuality?,
+138 -1
HealthProbe/Services/HealthKitService.swift
@@ -76,6 +76,7 @@ final class HealthKitService {
76 76
         triggerReason: String = "manual",
77 77
         retryOfSnapshotID: UUID? = nil,
78 78
         timeoutMultiplier: Double = 1,
79
+        reviewAmbiguousCompleteDisappearedTypes: Bool = false,
79 80
         progress: SnapshotFetchProgress? = nil
80 81
     ) async throws -> HealthSnapshot {
81 82
         let active = Self.allTypes
@@ -109,6 +110,12 @@ final class HealthKitService {
109 110
         }
110 111
         snapshot.typeCounts = typeCounts
111 112
 
113
+        applyStickyUnavailableState(
114
+            snapshot: snapshot,
115
+            typeCounts: typeCounts,
116
+            context: context
117
+        )
118
+
112 119
         // Invariant assertions before save — debug asserts + release silent correction
113 120
         for tc in typeCounts {
114 121
             let isComplete = tc.quality == SnapshotQuality.complete
@@ -135,6 +142,12 @@ final class HealthKitService {
135 142
             context: context
136 143
         )
137 144
 
145
+        if snapshot.snapshotQuality == .complete,
146
+           reviewAmbiguousCompleteDisappearedTypes,
147
+           hasAmbiguousCompleteDisappearance(snapshot: snapshot, typeCounts: typeCounts, context: context) {
148
+            return snapshot
149
+        }
150
+
138 151
         if snapshot.snapshotQuality == .complete {
139 152
             try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
140 153
         }
@@ -159,6 +172,24 @@ final class HealthKitService {
159 172
         return snapshot
160 173
     }
161 174
 
175
+    @MainActor
176
+    func saveReviewedCompleteSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
177
+        let typeCounts = snapshot.typeCounts ?? []
178
+        snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)
179
+        guard snapshot.snapshotQuality == .complete else {
180
+            return try await savePartialSnapshot(snapshot, in: context)
181
+        }
182
+
183
+        configureSnapshotMetadata(
184
+            snapshot,
185
+            typeCounts: typeCounts,
186
+            intendedTypeIDs: typeCounts.map(\.typeIdentifier),
187
+            context: context
188
+        )
189
+        try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
190
+        return snapshot
191
+    }
192
+
162 193
     // MARK: - Snapshot persistence
163 194
 
164 195
     private func persistSnapshot(
@@ -231,6 +262,62 @@ final class HealthKitService {
231 262
         snapshot.appBuildVersion = appBuildVersion()
232 263
     }
233 264
 
265
+    private func applyStickyUnavailableState(
266
+        snapshot: HealthSnapshot,
267
+        typeCounts: [TypeCount],
268
+        context: ModelContext
269
+    ) {
270
+        guard let previous = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context) else {
271
+            return
272
+        }
273
+
274
+        let previousByType = Dictionary(
275
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
276
+        )
277
+
278
+        for current in typeCounts {
279
+            guard current.quality == .complete,
280
+                  current.count == 0,
281
+                  let previousType = previousByType[current.typeIdentifier],
282
+                  previousType.quality == .unauthorized else {
283
+                continue
284
+            }
285
+
286
+            current.count = -1
287
+            current.contentHash = ""
288
+            current.earliestDate = nil
289
+            current.latestDate = nil
290
+            current.quality = .unauthorized
291
+            current.yearlyCounts?.removeAll()
292
+        }
293
+    }
294
+
295
+    private func hasAmbiguousCompleteDisappearance(
296
+        snapshot: HealthSnapshot,
297
+        typeCounts: [TypeCount],
298
+        context: ModelContext
299
+    ) -> Bool {
300
+        guard let previousID = snapshot.previousSnapshotID,
301
+              let previous = fetchSnapshot(id: previousID, context: context) else {
302
+            return false
303
+        }
304
+
305
+        let previousByType = Dictionary(
306
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
307
+        )
308
+
309
+        return typeCounts.contains { current in
310
+            guard current.quality == .complete,
311
+                  current.count == 0,
312
+                  let previous = previousByType[current.typeIdentifier],
313
+                  previous.quality == .complete,
314
+                  previous.count > 0 else {
315
+                return false
316
+            }
317
+            return true
318
+        }
319
+    }
320
+
234 321
     // MARK: - Post-save pipeline
235 322
 
236 323
     private func runPostSavePipeline(
@@ -249,10 +336,26 @@ final class HealthKitService {
249 336
 
250 337
         // Build type count maps for AnomalyDetector (never access relationship properties directly)
251 338
         let currentTypeCounts = Dictionary(uniqueKeysWithValues: typeCounts.map { ($0.typeIdentifier, $0) })
252
-        let previousTypeCounts = Dictionary(
339
+                var previousTypeCounts = Dictionary(
253 340
             uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
254 341
         )
255 342
 
343
+                for (typeID, currentType) in currentTypeCounts {
344
+                        guard currentType.quality == .complete,
345
+                                    currentType.count > 0,
346
+                                    let immediatePreviousType = previousTypeCounts[typeID],
347
+                                    immediatePreviousType.quality == .unauthorized,
348
+                                    let historicalBaseline = findLastCompleteValuedTypeCount(
349
+                                        typeID: typeID,
350
+                                        before: previous,
351
+                                        context: context
352
+                                    ) else {
353
+                                continue
354
+                        }
355
+
356
+                        previousTypeCounts[typeID] = historicalBaseline
357
+                }
358
+
256 359
         let detection = AnomalyDetector.detect(
257 360
             delta: delta,
258 361
             current: snapshot,
@@ -758,6 +861,40 @@ final class HealthKitService {
758 861
         return try? context.fetch(descriptor).first
759 862
     }
760 863
 
864
+    private func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
865
+        let descriptor = FetchDescriptor<HealthSnapshot>(
866
+            predicate: #Predicate<HealthSnapshot> { $0.id == id }
867
+        )
868
+        return try? context.fetch(descriptor).first
869
+    }
870
+
871
+    private func findLastCompleteValuedTypeCount(
872
+        typeID: String,
873
+        before snapshot: HealthSnapshot,
874
+        context: ModelContext
875
+    ) -> TypeCount? {
876
+        var visited: Set<UUID> = []
877
+        var cursorID = snapshot.previousSnapshotID
878
+
879
+        while let snapshotID = cursorID, !visited.contains(snapshotID) {
880
+            visited.insert(snapshotID)
881
+
882
+            guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
883
+                break
884
+            }
885
+
886
+            if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
887
+               candidate.quality == .complete,
888
+               candidate.count > 0 {
889
+                return candidate
890
+            }
891
+
892
+            cursorID = historicalSnapshot.previousSnapshotID
893
+        }
894
+
895
+        return nil
896
+    }
897
+
761 898
     private func isStoreEmpty(context: ModelContext) -> Bool {
762 899
         let descriptor = FetchDescriptor<HealthSnapshot>()
763 900
         return (try? context.fetch(descriptor).isEmpty) ?? true
+13 -3
HealthProbe/Services/SnapshotDiffService.swift
@@ -44,9 +44,19 @@ final class SnapshotDiffService {
44 44
     }
45 45
 
46 46
     func totalAbsoluteChange(current: HealthSnapshot, baseline: HealthSnapshot) -> Int {
47
-        diff(current: current, baseline: baseline)
48
-            .filter { $0.previousTracked }
49
-            .reduce(0) { $0 + abs($1.delta) }
47
+        let baselineMap = Dictionary(
48
+            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
49
+        )
50
+
51
+        return (current.typeCounts ?? []).reduce(0) { partial, currentType in
52
+            guard currentType.quality == .complete,
53
+                  let previousType = baselineMap[currentType.typeIdentifier],
54
+                  previousType.quality == .complete else {
55
+                return partial
56
+            }
57
+
58
+            return partial + abs(currentType.count - previousType.count)
59
+        }
50 60
     }
51 61
 
52 62
     func apply(filter: DiffFilter, to diffs: [TypeDiff]) -> [TypeDiff] {
+8 -0
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -218,6 +218,14 @@ final class SnapshotFetchProgress {
218 218
         types[index].successCount = successCount
219 219
     }
220 220
 
221
+    func markUnavailable(_ id: String) {
222
+        let index = visibleTypeIndex(for: id)
223
+        types[index].status = .failed("Not authorized")
224
+        types[index].quality = SnapshotQuality.unauthorized.rawValue
225
+        types[index].recordCount = -1
226
+        types[index].authorizationStatus = "unavailable"
227
+    }
228
+
221 229
     private func visibleTypeIndex(for id: String) -> Int {
222 230
         if let index = types.firstIndex(where: { $0.id == id }) {
223 231
             return index
+166 -5
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -22,10 +22,12 @@ final class DashboardViewModel {
22 22
     var completedSnapshotDeviceID: String? = nil
23 23
     var completedSnapshotTriggerReason: String? = nil
24 24
     var completedSnapshotRetryOfSnapshotID: UUID? = nil
25
+    var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = []
25 26
 
26 27
     private let healthKit = HealthKitService.shared
27 28
     private let diffService = SnapshotDiffService.shared
28 29
     private var pendingPartialSnapshot: HealthSnapshot?
30
+    private var pendingAmbiguousSnapshot: HealthSnapshot?
29 31
 
30 32
     func requestAuthorization() async {
31 33
         isRequestingAuth = true
@@ -64,6 +66,8 @@ final class DashboardViewModel {
64 66
         completedSnapshotTriggerReason = nil
65 67
         completedSnapshotRetryOfSnapshotID = nil
66 68
         pendingPartialSnapshot = nil
69
+        pendingAmbiguousSnapshot = nil
70
+        ambiguousDisappearedMetrics = []
67 71
         snapshotProgressMessage = ""
68 72
         snapshotProgressDetail = ""
69 73
         canRetryWithPermissions = false
@@ -92,6 +96,7 @@ final class DashboardViewModel {
92 96
                     triggerReason: triggerReason,
93 97
                     retryOfSnapshotID: retryOfSnapshotID,
94 98
                     timeoutMultiplier: timeoutMultiplier,
99
+                    reviewAmbiguousCompleteDisappearedTypes: triggerReason == "manual",
95 100
                     progress: self.fetchProgress
96 101
                 )
97 102
             }
@@ -110,6 +115,8 @@ final class DashboardViewModel {
110 115
                 monitoredRegistryVersion: snapshot.monitoredRegistryVersion
111 116
             )
112 117
 
118
+            reflectUnavailableMetricsInProgress(snapshot: snapshot)
119
+
113 120
             if snapshot.snapshotQuality != SnapshotQuality.complete {
114 121
                 pendingPartialSnapshot = snapshot
115 122
 
@@ -124,6 +131,13 @@ final class DashboardViewModel {
124 131
                 snapshotProgressMessage = "Incomplete snapshot"
125 132
                 permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore
126 133
 
134
+                if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount, context: context) {
135
+                    snapshotProgressMessage = "Known unavailable metrics"
136
+                    snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot."
137
+                    await savePartialSnapshot(context: context, keepSheetOpenForReview: true)
138
+                    return
139
+                }
140
+
127 141
                 if unauthorizedCount > 0 {
128 142
                     snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))"
129 143
                     let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")
@@ -157,6 +171,17 @@ final class DashboardViewModel {
157 171
                 return
158 172
             }
159 173
 
174
+            let ambiguousMetrics = findAmbiguousDisappearedMetrics(snapshot: snapshot, context: context)
175
+            if !ambiguousMetrics.isEmpty {
176
+                pendingAmbiguousSnapshot = snapshot
177
+                ambiguousDisappearedMetrics = ambiguousMetrics
178
+                snapshotProgress = .requiresResolution
179
+                snapshotProgressMessage = "Metric access needs review"
180
+                snapshotProgressDetail = "One or more metrics returned zero samples after previously having data. Choose how HealthProbe should classify this before saving the snapshot."
181
+                showProgressSheet = true
182
+                return
183
+            }
184
+
160 185
             let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
161 186
             let exists = allSnapshots.contains { $0.id == snapshot.id }
162 187
 
@@ -180,6 +205,42 @@ final class DashboardViewModel {
180 205
         }
181 206
     }
182 207
 
208
+    func treatAmbiguousMetricsAsUnauthorized(context: ModelContext) async {
209
+        guard let snapshot = pendingAmbiguousSnapshot else { return }
210
+        let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id))
211
+        let typeCounts = snapshot.typeCounts ?? []
212
+
213
+        for typeCount in typeCounts where ambiguousIDs.contains(typeCount.typeIdentifier) {
214
+            typeCount.count = -1
215
+            typeCount.contentHash = ""
216
+            typeCount.earliestDate = nil
217
+            typeCount.latestDate = nil
218
+            typeCount.quality = .unauthorized
219
+            typeCount.yearlyCounts?.removeAll()
220
+        }
221
+        snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts)
222
+
223
+        do {
224
+            let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
225
+            finishSavedReviewedSnapshot(saved)
226
+        } catch {
227
+            snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
228
+            showProgressSheet = true
229
+        }
230
+    }
231
+
232
+    func treatAmbiguousMetricsAsDeleted(context: ModelContext) async {
233
+        guard let snapshot = pendingAmbiguousSnapshot else { return }
234
+
235
+        do {
236
+            let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: context)
237
+            finishSavedReviewedSnapshot(saved)
238
+        } catch {
239
+            snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
240
+            showProgressSheet = true
241
+        }
242
+    }
243
+
183 244
     func retryWithPermissions(context: ModelContext, selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
184 245
         isRequestingAuth = true
185 246
         defer { isRequestingAuth = false }
@@ -214,17 +275,23 @@ final class DashboardViewModel {
214 275
         )
215 276
     }
216 277
 
217
-    func savePartialSnapshot(context: ModelContext) async {
278
+    func savePartialSnapshot(context: ModelContext, keepSheetOpenForReview: Bool = false) async {
218 279
         guard let snapshot = pendingPartialSnapshot else {
219
-            fetchProgress = nil
220
-            showProgressSheet = false
221
-            snapshotProgress = .idle
280
+            if !keepSheetOpenForReview {
281
+                fetchProgress = nil
282
+                showProgressSheet = false
283
+                snapshotProgress = .idle
284
+            }
222 285
             return
223 286
         }
224 287
 
225 288
         do {
226 289
             let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
227 290
             completedSnapshotID = saved.id
291
+            completedSnapshotTimestamp = saved.timestamp
292
+            completedSnapshotDeviceID = saved.deviceID
293
+            completedSnapshotTriggerReason = saved.triggerReason
294
+            completedSnapshotRetryOfSnapshotID = saved.retryOfSnapshotID
228 295
             pendingPartialSnapshot = nil
229 296
             fetchProgress?.updateChainContext(
230 297
                 previousSnapshotID: saved.previousSnapshotID,
@@ -239,6 +306,12 @@ final class DashboardViewModel {
239 306
             return
240 307
         }
241 308
 
309
+        if keepSheetOpenForReview {
310
+            snapshotProgress = .incomplete
311
+            showProgressSheet = true
312
+            return
313
+        }
314
+
242 315
         fetchProgress = nil
243 316
         showProgressSheet = false
244 317
         snapshotProgress = .idle
@@ -246,6 +319,8 @@ final class DashboardViewModel {
246 319
 
247 320
     func discardSnapshot(context: ModelContext) async {
248 321
         pendingPartialSnapshot = nil
322
+        pendingAmbiguousSnapshot = nil
323
+        ambiguousDisappearedMetrics = []
249 324
         if let snapshotID = completedSnapshotID {
250 325
             do {
251 326
                 let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
@@ -276,6 +351,90 @@ final class DashboardViewModel {
276 351
             return result
277 352
         }
278 353
     }
354
+
355
+    private func findAmbiguousDisappearedMetrics(snapshot: HealthSnapshot, context: ModelContext) -> [AmbiguousDisappearedMetric] {
356
+        guard let previousID = snapshot.previousSnapshotID else { return [] }
357
+        let descriptor = FetchDescriptor<HealthSnapshot>(
358
+            predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
359
+        )
360
+        guard let previous = try? context.fetch(descriptor).first else { return [] }
361
+
362
+        let previousByType = Dictionary(
363
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
364
+        )
365
+
366
+        return (snapshot.typeCounts ?? []).compactMap { current in
367
+            guard current.quality == .complete,
368
+                  current.count == 0,
369
+                  let previous = previousByType[current.typeIdentifier],
370
+                  previous.quality == .complete,
371
+                  previous.count > 0 else {
372
+                return nil
373
+            }
374
+            return AmbiguousDisappearedMetric(
375
+                id: current.typeIdentifier,
376
+                displayName: current.displayName,
377
+                previousCount: previous.count
378
+            )
379
+        }.sorted { $0.displayName < $1.displayName }
380
+    }
381
+
382
+    private func reflectUnavailableMetricsInProgress(snapshot: HealthSnapshot) {
383
+        guard let fetchProgress else { return }
384
+        for typeCount in snapshot.typeCounts ?? [] where typeCount.quality == .unauthorized {
385
+            fetchProgress.markUnavailable(typeCount.typeIdentifier)
386
+        }
387
+    }
388
+
389
+    private func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot, failedCount: Int, context: ModelContext) -> Bool {
390
+        guard failedCount == 0,
391
+              let previousID = snapshot.previousSnapshotID else {
392
+            return false
393
+        }
394
+
395
+        let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
396
+        guard !currentUnauthorized.isEmpty else { return false }
397
+
398
+        let descriptor = FetchDescriptor<HealthSnapshot>(
399
+            predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
400
+        )
401
+        guard let previous = try? context.fetch(descriptor).first else {
402
+            return false
403
+        }
404
+
405
+        let previousByType = Dictionary(
406
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
407
+        )
408
+
409
+        return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
410
+    }
411
+
412
+    private func finishSavedReviewedSnapshot(_ snapshot: HealthSnapshot) {
413
+        completedSnapshotID = snapshot.id
414
+        completedSnapshotTimestamp = snapshot.timestamp
415
+        completedSnapshotDeviceID = snapshot.deviceID
416
+        completedSnapshotTriggerReason = snapshot.triggerReason
417
+        completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
418
+        pendingAmbiguousSnapshot = nil
419
+        pendingPartialSnapshot = nil
420
+        ambiguousDisappearedMetrics = []
421
+        fetchProgress?.updateChainContext(
422
+            previousSnapshotID: snapshot.previousSnapshotID,
423
+            isChainStart: snapshot.isChainStart,
424
+            snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
425
+            monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
426
+            monitoredRegistryVersion: snapshot.monitoredRegistryVersion
427
+        )
428
+        fetchProgress = nil
429
+        showProgressSheet = false
430
+        snapshotProgress = .idle
431
+    }
432
+}
433
+
434
+struct AmbiguousDisappearedMetric: Identifiable, Equatable {
435
+    let id: String
436
+    let displayName: String
437
+    let previousCount: Int
279 438
 }
280 439
 
281 440
 enum SnapshotProgress {
@@ -284,6 +443,7 @@ enum SnapshotProgress {
284 443
     case processing
285 444
     case complete
286 445
     case incomplete
446
+    case requiresResolution
287 447
 
288 448
     var message: String {
289 449
         switch self {
@@ -292,12 +452,13 @@ enum SnapshotProgress {
292 452
         case .processing: return "Processing snapshot..."
293 453
         case .complete: return "Snapshot created successfully!"
294 454
         case .incomplete: return "Snapshot created with issues"
455
+        case .requiresResolution: return "Snapshot needs review"
295 456
         }
296 457
     }
297 458
 
298 459
     var isError: Bool {
299 460
         switch self {
300
-        case .incomplete: return true
461
+        case .incomplete, .requiresResolution: return true
301 462
         default: return false
302 463
         }
303 464
     }
+237 -32
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -35,6 +35,11 @@ struct DashboardView: View {
35 35
         return latest.deviceName
36 36
     }
37 37
 
38
+    private var latestUnavailableMetricCount: Int {
39
+        guard let latest else { return 0 }
40
+        return (latest.typeCounts ?? []).filter { $0.quality == .unauthorized }.count
41
+    }
42
+
38 43
     var body: some View {
39 44
         NavigationStack {
40 45
             List {
@@ -110,6 +115,7 @@ struct DashboardView: View {
110 115
     private func qualityLabel(progress: SnapshotFetchProgress) -> String {
111 116
         switch viewModel.snapshotProgress {
112 117
         case .complete: return "Complete"
118
+        case .requiresResolution: return "Needs decision"
113 119
         case .incomplete:
114 120
             let hasUnauthorized = failedTypesList(in: progress).contains {
115 121
                 if case .failed(let r) = $0.status { return r == "Not authorized" }
@@ -123,6 +129,7 @@ struct DashboardView: View {
123 129
     private func qualityColor(progress: SnapshotFetchProgress) -> Color {
124 130
         switch viewModel.snapshotProgress {
125 131
         case .complete:   return .healthyGreen
132
+        case .requiresResolution: return .warningAmber
126 133
         case .incomplete: return .warningAmber
127 134
         default:          return .neutralGray
128 135
         }
@@ -142,13 +149,21 @@ struct DashboardView: View {
142 149
         }
143 150
     }
144 151
 
145
-private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
152
+    private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
146 153
         let failed = failedTypesList(in: progress)
147 154
         var items: [String] = []
155
+        let unauthorized = failed.filter {
156
+            if case .failed(let r) = $0.status { return r == "Not authorized" }
157
+            return false
158
+        }
148 159
         let timedOut = failed.filter {
149 160
             if case .failed(let r) = $0.status { return r == "Timeout" }
150 161
             return false
151 162
         }
163
+        if !unauthorized.isEmpty {
164
+            items.append("Open iOS Settings, choose HealthProbe, and re-enable Health read access for the listed metrics.")
165
+            items.append("After restoring access, create a new snapshot before relying on checksum or anomaly results.")
166
+        }
152 167
         if !timedOut.isEmpty {
153 168
             items.append("Timeout reflects HealthProbe's configured limit, not missing Health data or permission.")
154 169
         }
@@ -158,6 +173,21 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
158 173
         return items
159 174
     }
160 175
 
176
+    private func ambiguousDisappearanceNotes() -> [String] {
177
+        [
178
+            "All values disappeared for the listed metric. Apple API does not allow HealthProbe to verify Health read authorization status.",
179
+            "Possible causes: the metric data was deleted, or Health read authorization was withdrawn."
180
+        ]
181
+    }
182
+
183
+    private func ambiguousDisappearanceActions() -> [String] {
184
+        [
185
+            "Treat the metric as unauthorized.",
186
+            "Treat the metric as totally deleted.",
187
+            "Cancel saving this snapshot."
188
+        ]
189
+    }
190
+
161 191
     private func iso8601String(_ date: Date?) -> String {
162 192
         guard let date else { return "none" }
163 193
         return ISO8601DateFormatter().string(from: date)
@@ -174,6 +204,8 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
174 204
             return "complete_success"
175 205
         case .incomplete:
176 206
             return "partial_success"
207
+        case .requiresResolution:
208
+            return "requires_user_resolution"
177 209
         case .idle, .fetching, .processing:
178 210
             return "failed"
179 211
         }
@@ -244,6 +276,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
244 276
         switch viewModel.snapshotProgress {
245 277
         case .complete:   quality = "complete"
246 278
         case .incomplete: quality = "partial"
279
+        case .requiresResolution: quality = "requires_resolution"
247 280
         default:          quality = "unknown"
248 281
         }
249 282
         let duration = viewModel.fetchDurationSeconds.map { formatDuration($0) } ?? "unknown"
@@ -253,6 +286,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
253 286
         let tsStr = fmt.string(from: timestamp)
254 287
         let degraded = degradedTypesList(in: progress)
255 288
         let isPartialSnapshot = viewModel.snapshotProgress == .incomplete
289
+        let requiresResolution = viewModel.snapshotProgress == .requiresResolution
256 290
         let failedLines: String
257 291
         if degraded.isEmpty {
258 292
             failedLines = "FAILED/DEGRADED METRICS: none"
@@ -287,8 +321,10 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
287 321
         lines.append("")
288 322
         lines.append("INTERPRETATION_HINTS")
289 323
         lines.append("Partial snapshot: \(isPartialSnapshot ? "true" : "false")")
324
+        lines.append("Requires user resolution: \(requiresResolution ? "true" : "false")")
290 325
         lines.append("Failed metrics are excluded from checksum/anomaly detection")
291 326
         lines.append("Do not infer deletion from partial snapshots")
327
+        lines.append("All-values-disappeared metrics require classification before saving")
292 328
         lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
293 329
         lines.append("")
294 330
         lines.append("CONFIGURATION")
@@ -310,6 +346,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
310 346
         lines.append("")
311 347
         lines.append(failedLines)
312 348
 
349
+        if requiresResolution {
350
+            lines.append("")
351
+            lines.append("AMBIGUOUS TOTAL DISAPPEARANCE")
352
+            for metric in viewModel.ambiguousDisappearedMetrics {
353
+                lines.append("  \(metric.displayName): previousCount=\(metric.previousCount), currentCount=0")
354
+            }
355
+            lines.append("  note: Apple API does not expose read authorization status for this decision point")
356
+        }
357
+
313 358
         lines.append("")
314 359
         lines.append("HEALTHKIT API RESULTS")
315 360
         let orderedTypes = progress.types.sorted(by: { $0.displayName < $1.displayName })
@@ -423,7 +468,8 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
423 468
         case .pending: return .gray
424 469
         case .fetching: return .blue
425 470
         case .complete: return .healthyGreen
426
-        case .failed: return .criticalRed
471
+        case .failed(let reason):
472
+            return reason == "Not authorized" ? .warningAmber : .criticalRed
427 473
         }
428 474
     }
429 475
 
@@ -447,7 +493,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
447 493
 
448 494
     private var shouldShowFetchProgress: Bool {
449 495
         switch viewModel.snapshotProgress {
450
-        case .fetching, .complete, .incomplete:
496
+        case .fetching, .complete, .incomplete, .requiresResolution:
451 497
             return viewModel.fetchProgress != nil
452 498
         case .idle, .processing:
453 499
             return false
@@ -456,7 +502,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
456 502
 
457 503
     private var shouldShowFetchReport: Bool {
458 504
         switch viewModel.snapshotProgress {
459
-        case .complete, .incomplete:
505
+        case .complete, .incomplete, .requiresResolution:
460 506
             return true
461 507
         case .idle, .fetching, .processing:
462 508
             return false
@@ -465,14 +511,22 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
465 511
 
466 512
     private func fetchReportView(_ progress: SnapshotFetchProgress) -> some View {
467 513
         let isIncomplete = viewModel.snapshotProgress == .incomplete
514
+        let requiresResolution = viewModel.snapshotProgress == .requiresResolution
468 515
         let failed = isIncomplete ? failedTypesList(in: progress) : []
469 516
         let noteItems  = isIncomplete ? remediationNoteItems(progress: progress) : []
517
+        let remediationTitle = hasAuthorizationFailures(progress) ? "WHAT TO DO" : "NOTES"
470 518
         return VStack(alignment: .leading, spacing: 20) {
471 519
             reportSummarySection(progress)
472 520
             if !failed.isEmpty { reportIssuesSection(failed) }
473
-            if !noteItems.isEmpty { reportRemediationSection(noteItems, title: "NOTES") }
521
+            if requiresResolution {
522
+                ambiguousDisappearanceSection()
523
+                reportRemediationSection(ambiguousDisappearanceNotes(), title: "NOTES")
524
+                reportRemediationSection(ambiguousDisappearanceActions(), title: "WHAT TO DO")
525
+            }
526
+            if !noteItems.isEmpty { reportRemediationSection(noteItems, title: remediationTitle) }
474 527
             if isIncomplete, hasTimedOutMetrics(progress) { reportDecisionOverviewSection() }
475 528
             if isIncomplete { snapshotResultActionsSection }
529
+            if requiresResolution { ambiguousResolutionActionsSection }
476 530
         }
477 531
     }
478 532
 
@@ -483,14 +537,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
483 537
                 .foregroundStyle(.secondary)
484 538
 
485 539
             let isComplete = viewModel.snapshotProgress == .complete
540
+            let requiresResolution = viewModel.snapshotProgress == .requiresResolution
486 541
             HStack(spacing: 10) {
487 542
                 Image(systemName: isComplete ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
488 543
                     .font(.title3)
489 544
                     .foregroundStyle(isComplete ? Color.healthyGreen : Color.warningAmber)
490 545
                 VStack(alignment: .leading, spacing: 2) {
491
-                    Text(isComplete ? "Snapshot created successfully" : "Partial snapshot")
546
+                    Text(isComplete ? "Snapshot created successfully" : (requiresResolution ? "Snapshot needs review" : "Partial snapshot"))
492 547
                         .font(.subheadline.weight(.semibold))
493
-                    Text(isComplete ? "All monitored metrics loaded" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed")
548
+                    Text(isComplete ? "All monitored metrics loaded" : (requiresResolution ? "\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") need classification" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed"))
494 549
                         .font(.caption)
495 550
                         .foregroundStyle(.secondary)
496 551
                 }
@@ -533,6 +588,39 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
533 588
         }
534 589
     }
535 590
 
591
+    private func ambiguousDisappearanceSection() -> some View {
592
+        VStack(alignment: .leading, spacing: 6) {
593
+            Text("ISSUES")
594
+                .font(.caption.weight(.semibold))
595
+                .foregroundStyle(.secondary)
596
+
597
+            Text("\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") returned zero samples")
598
+                .font(.subheadline.weight(.semibold))
599
+                .foregroundStyle(Color.warningAmber)
600
+
601
+            VStack(spacing: 4) {
602
+                ForEach(viewModel.ambiguousDisappearedMetrics) { metric in
603
+                    HStack(spacing: 8) {
604
+                        Image(systemName: "questionmark.circle.fill")
605
+                            .foregroundStyle(Color.warningAmber)
606
+                            .font(.caption)
607
+                            .frame(width: 14)
608
+                        Text(metric.displayName)
609
+                            .font(.subheadline.weight(.semibold))
610
+                        Spacer()
611
+                        Text("all values disappeared")
612
+                            .font(.caption)
613
+                            .foregroundStyle(.secondary)
614
+                    }
615
+                    .padding(.horizontal, 12)
616
+                    .padding(.vertical, 10)
617
+                    .background(Color(.systemGray6))
618
+                    .cornerRadius(8)
619
+                }
620
+            }
621
+        }
622
+    }
623
+
536 624
     private func reportIssuesSection(_ failed: [SnapshotFetchProgress.TypeProgress]) -> some View {
537 625
         VStack(alignment: .leading, spacing: 6) {
538 626
             Text("ISSUES")
@@ -693,6 +781,11 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
693 781
                                     .font(.caption2)
694 782
                                     .foregroundStyle(.secondary)
695 783
                                     .lineLimit(1)
784
+                            } else if case .failed(let reason) = type.status, reason == "Not authorized" {
785
+                                Text("Unavailable")
786
+                                    .font(.caption2)
787
+                                    .foregroundStyle(Color.warningAmber)
788
+                                    .lineLimit(1)
696 789
                             }
697 790
                         }
698 791
                         .padding(.horizontal, 10)
@@ -766,7 +859,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
766 859
                         Image(systemName: "checkmark.circle.fill")
767 860
                             .font(.caption)
768 861
                             .foregroundStyle(Color.healthyGreen)
769
-                    } else if viewModel.snapshotProgress == .incomplete {
862
+                    } else if viewModel.snapshotProgress == .incomplete || viewModel.snapshotProgress == .requiresResolution {
770 863
                         Image(systemName: "exclamationmark.circle.fill")
771 864
                             .font(.caption)
772 865
                             .foregroundStyle(Color.warningAmber)
@@ -845,12 +938,12 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
845 938
         }
846 939
         .presentationDetents(sheetDetents)
847 940
         .presentationDragIndicator(.visible)
848
-        .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.isRequestingAuth)
941
+        .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.snapshotProgress == .requiresResolution || viewModel.isRequestingAuth)
849 942
         .onAppear {
850 943
             snapshotSheetTab = .progress
851 944
         }
852 945
         .onChange(of: viewModel.snapshotProgress) { _, newProgress in
853
-            if newProgress == .incomplete {
946
+            if newProgress == .incomplete || newProgress == .requiresResolution {
854 947
                 snapshotSheetTab = .report
855 948
             } else if newProgress == .fetching {
856 949
                 snapshotSheetTab = .progress
@@ -860,28 +953,44 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
860 953
 
861 954
     @ViewBuilder
862 955
     private var snapshotResultActionsSection: some View {
956
+        let hasAuthorizationIssue = hasAuthorizationFailures(viewModel.fetchProgress)
957
+        let hasTimeoutIssue = hasTimedOutMetrics(viewModel.fetchProgress)
958
+
863 959
         HStack(spacing: 12) {
864
-            Button {
865
-                Task {
866
-                    if viewModel.canRetryWithPermissions {
867
-                        await viewModel.retryWithPermissions(
868
-                            context: modelContext,
869
-                            selectedTypeIDs: appSettings.selectedTypeIDs,
870
-                            adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
871
-                        )
872
-                    } else {
873
-                        await viewModel.retryFailedMetricsWithExtendedTimeout(
874
-                            context: modelContext,
875
-                            selectedTypeIDs: appSettings.selectedTypeIDs,
876
-                            adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
877
-                        )
960
+            if hasAuthorizationIssue && !viewModel.canRetryWithPermissions {
961
+                Button {
962
+                    if let url = URL(string: UIApplication.openSettingsURLString) {
963
+                        UIApplication.shared.open(url)
878 964
                     }
965
+                } label: {
966
+                    Text("Open Settings").frame(maxWidth: .infinity)
879 967
                 }
880
-            } label: {
881
-                Text("Retry").frame(maxWidth: .infinity)
968
+                .buttonStyle(.borderedProminent)
969
+                .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
970
+                .accessibilityLabel("Open app settings to restore HealthKit read access")
971
+            } else if viewModel.canRetryWithPermissions || hasTimeoutIssue {
972
+                Button {
973
+                    Task {
974
+                        if viewModel.canRetryWithPermissions {
975
+                            await viewModel.retryWithPermissions(
976
+                                context: modelContext,
977
+                                selectedTypeIDs: appSettings.selectedTypeIDs,
978
+                                adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
979
+                            )
980
+                        } else {
981
+                            await viewModel.retryFailedMetricsWithExtendedTimeout(
982
+                                context: modelContext,
983
+                                selectedTypeIDs: appSettings.selectedTypeIDs,
984
+                                adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
985
+                            )
986
+                        }
987
+                    }
988
+                } label: {
989
+                    Text("Retry").frame(maxWidth: .infinity)
990
+                }
991
+                .buttonStyle(.borderedProminent)
992
+                .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
882 993
             }
883
-            .buttonStyle(.borderedProminent)
884
-            .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
885 994
 
886 995
             Button {
887 996
                 Task { await viewModel.savePartialSnapshot(context: modelContext) }
@@ -903,6 +1012,41 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
903 1012
         }
904 1013
     }
905 1014
 
1015
+    private var ambiguousResolutionActionsSection: some View {
1016
+        VStack(spacing: 10) {
1017
+            Button {
1018
+                Task { await viewModel.treatAmbiguousMetricsAsUnauthorized(context: modelContext) }
1019
+            } label: {
1020
+                Label("Treat as Unauthorized", systemImage: "lock.slash")
1021
+                    .frame(maxWidth: .infinity)
1022
+            }
1023
+            .buttonStyle(.borderedProminent)
1024
+            .disabled(viewModel.isCreatingSnapshot)
1025
+            .accessibilityLabel("Treat disappeared metrics as unauthorized")
1026
+
1027
+            Button {
1028
+                Task { await viewModel.treatAmbiguousMetricsAsDeleted(context: modelContext) }
1029
+            } label: {
1030
+                Label("Treat as Deleted", systemImage: "trash")
1031
+                    .frame(maxWidth: .infinity)
1032
+            }
1033
+            .buttonStyle(.bordered)
1034
+            .disabled(viewModel.isCreatingSnapshot)
1035
+            .accessibilityLabel("Treat disappeared metrics as deleted")
1036
+
1037
+            Button {
1038
+                Task { await viewModel.discardSnapshot(context: modelContext) }
1039
+            } label: {
1040
+                Label("Cancel Save", systemImage: "xmark")
1041
+                    .foregroundStyle(Color.criticalRed)
1042
+                    .frame(maxWidth: .infinity)
1043
+            }
1044
+            .buttonStyle(.bordered)
1045
+            .disabled(viewModel.isCreatingSnapshot)
1046
+            .accessibilityLabel("Cancel saving this snapshot")
1047
+        }
1048
+    }
1049
+
906 1050
     private var sheetDetents: Set<PresentationDetent> {
907 1051
         [.large]
908 1052
     }
@@ -921,9 +1065,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
921 1065
                         .foregroundStyle(.secondary)
922 1066
                 }
923 1067
                 if latest.snapshotQuality != SnapshotQuality.complete {
924
-                    Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
925
-                        .font(.caption)
926
-                        .foregroundStyle(Color.warningAmber)
1068
+                    if latestUnavailableMetricCount > 0 {
1069
+                        Label("\(latestUnavailableMetricCount) metric\(latestUnavailableMetricCount == 1 ? "" : "s") unavailable", systemImage: "exclamationmark.triangle")
1070
+                            .font(.caption)
1071
+                            .foregroundStyle(Color.warningAmber)
1072
+                    } else {
1073
+                        Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
1074
+                            .font(.caption)
1075
+                            .foregroundStyle(Color.warningAmber)
1076
+                    }
927 1077
                 }
928 1078
             } else {
929 1079
                 Label("No snapshots yet", systemImage: "camera.viewfinder")
@@ -983,7 +1133,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
983 1133
 
984 1134
 private enum SnapshotSheetTab: String, CaseIterable, Identifiable {
985 1135
     case progress = "Progress"
986
-    case report = "Report"
1136
+    case report = "Result"
987 1137
 
988 1138
     var id: Self { self }
989 1139
 }
@@ -1108,11 +1258,43 @@ private struct DiagnosticReportSheet: View {
1108 1258
 }
1109 1259
 
1110 1260
 private struct AnomalySummarySection: View {
1261
+    @Environment(\.modelContext) private var modelContext
1111 1262
     @Query(filter: #Predicate<AnomalyRecord> { !$0.isResolved })
1112 1263
     private var unresolved: [AnomalyRecord]
1113 1264
 
1114 1265
     private var criticalCount: Int { unresolved.filter { $0.severityRaw == Severity.critical.rawValue }.count }
1115 1266
     private var warningCount:  Int { unresolved.filter { $0.severityRaw == Severity.warning.rawValue }.count }
1267
+    private var disappearedRecords: [AnomalyRecord] {
1268
+        unresolved
1269
+            .filter { $0.anomalyType == .deletion }
1270
+            .sorted { $0.detectedAt > $1.detectedAt }
1271
+    }
1272
+
1273
+    private func confirmAuthorizationChange(for anomaly: AnomalyRecord) {
1274
+        defer {
1275
+            anomaly.isResolved = true
1276
+            try? modelContext.save()
1277
+        }
1278
+
1279
+        guard let typeIdentifier = anomaly.typeIdentifier else { return }
1280
+        let snapshotID = anomaly.snapshotID
1281
+
1282
+        let descriptor = FetchDescriptor<HealthSnapshot>(
1283
+            predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
1284
+        )
1285
+        guard let snapshot = try? modelContext.fetch(descriptor).first,
1286
+              let typeCount = snapshot.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
1287
+            return
1288
+        }
1289
+
1290
+        typeCount.count = -1
1291
+        typeCount.contentHash = ""
1292
+        typeCount.earliestDate = nil
1293
+        typeCount.latestDate = nil
1294
+        typeCount.quality = .unauthorized
1295
+        typeCount.yearlyCounts?.removeAll()
1296
+        snapshot.snapshotQuality = HealthKitService.shared.deriveSnapshotQuality(from: snapshot.typeCounts ?? [])
1297
+    }
1116 1298
 
1117 1299
     var body: some View {
1118 1300
         if !unresolved.isEmpty {
@@ -1127,6 +1309,29 @@ private struct AnomalySummarySection: View {
1127 1309
                           systemImage: "exclamationmark.triangle.fill")
1128 1310
                         .foregroundStyle(Color.warningAmber)
1129 1311
                 }
1312
+                if !disappearedRecords.isEmpty {
1313
+                    VStack(alignment: .leading, spacing: 8) {
1314
+                        Label("Records disappeared", systemImage: "eye.slash")
1315
+                            .font(.subheadline.weight(.semibold))
1316
+                            .foregroundStyle(Color.criticalRed)
1317
+                        Text("Check Health read access for the affected metrics first. If you intentionally revoked access, confirm that here; otherwise investigate as possible data deletion.")
1318
+                            .font(.caption)
1319
+                            .foregroundStyle(.secondary)
1320
+                        ForEach(disappearedRecords.prefix(3)) { anomaly in
1321
+                            VStack(alignment: .leading, spacing: 6) {
1322
+                                Text(anomaly.message)
1323
+                                    .font(.caption)
1324
+                                    .foregroundStyle(.secondary)
1325
+                                Button("Confirm Authorization Change") {
1326
+                                    confirmAuthorizationChange(for: anomaly)
1327
+                                }
1328
+                                .buttonStyle(.bordered)
1329
+                                .controlSize(.small)
1330
+                            }
1331
+                        }
1332
+                    }
1333
+                    .padding(.vertical, 4)
1334
+                }
1130 1335
             }
1131 1336
         }
1132 1337
     }