From d099431110fe3ac431b63bbf91e0b33ea507d09e Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Mon, 19 Oct 2026 19:47:38 -0400 Subject: [PATCH 1/2] Preserve verification scope without inventing source reads --- BitMatchTests/ReportScannerTests.swift | 86 ++++++++++++++++++- .../Evidence/EvidenceReader.swift | 62 ++++++++++--- .../Evidence/VerificationScopeEvidence.swift | 14 +++ .../VerificationScopeEvidenceTests.swift | 66 ++++++++++++++ Shared/Core/Models/TransferModels.swift | 3 +- Shared/Core/Services/ReportScanner.swift | 13 +-- Shared/Views/TransferQueueSection.swift | 12 +++ docs/VERIFICATION_ASSURANCE_AUDIT.md | 6 +- 8 files changed, 242 insertions(+), 20 deletions(-) diff --git a/BitMatchTests/ReportScannerTests.swift b/BitMatchTests/ReportScannerTests.swift index 2f4f1b19..8fa202b7 100644 --- a/BitMatchTests/ReportScannerTests.swift +++ b/BitMatchTests/ReportScannerTests.swift @@ -147,7 +147,91 @@ struct ReportScannerTests { let cards = await ReportScanner.scan(at: root) #expect(cards.count == 1) #expect(cards.first?.verified == true) - #expect(cards.first?.metadata?.verificationMode == .standard) + #expect(cards.first?.metadata?.verificationMode == nil) + #expect(cards.first.map { MasterReportPresentation.statusText(for: $0).contains("mode not recorded") } == true) + } + + @Test func explicitThoroughScopeSurvivesMasterReportScan() throws { + let root = try makeTemporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .thorough, root: root, finished: Date())) as? [String: Any]) + var verification = try #require(object["verification"] as? [String: Any]) + verification["scope"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode( + VerificationScopeEvidence(selectedMode: .thorough, results: [ + ResultRow(path: "a", status: "✅ Verified", size: 10, checksum: "a", destination: "Backup"), + ResultRow(path: "b", status: "✅ Verified", size: 10, checksum: "b", destination: "Backup") + ]))) + object["verification"] = verification + let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object), + reportURL: root.appendingPathComponent("r.json"))) + #expect(card.verified) + #expect(card.metadata?.verificationMode == .thorough) + } + + @Test func legacyMD5ReportDoesNotClaimThorough() throws { + let root = try makeTemporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let data = try reportData(mode: .thorough, root: root, finished: Date()) + let card = try #require(ReportScanner.transferCard(reportData: data, reportURL: root.appendingPathComponent("r.json"))) + #expect(card.verified) // Preserve the older report's content-check result. + #expect(card.metadata?.verificationMode == nil) + #expect(MasterReportPresentation.statusText(for: card).contains("mode not recorded")) + } + + @Test func contradictoryQuickScopeCannotBecomeVerified() throws { + let root = try makeTemporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .standard, root: root, finished: Date())) as? [String: Any]) + var verification = try #require(object["verification"] as? [String: Any]) + verification["scope"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode( + VerificationScopeEvidence(selectedMode: .quick, results: []))) + object["verification"] = verification + let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object), + reportURL: root.appendingPathComponent("r.json"))) + #expect(card.verified == false) + #expect(card.metadata?.verificationMode == nil) + } + + @Test func savedScopeMustAgreeWithStatisticsBeforeMasterReportShowsVerified() throws { + let root = try makeTemporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + var object = try #require(JSONSerialization.jsonObject(with: reportData(mode: .standard, root: root, finished: Date())) as? [String: Any]) + var verification = try #require(object["verification"] as? [String: Any]) + verification["scope"] = [ + "selectedMode": "Standard", "countsBasis": "recorded-results-only", + "recordedSelectedSourceFiles": 2, "recordedSelectedResults": 2, + "recordedVerifiedResults": 1, "recordedExcludedSourceFiles": 0 + ] as [String: Any] + object["verification"] = verification + let card = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object), + reportURL: root.appendingPathComponent("r.json"))) + #expect(card.verified == false) + } + + @Test func twoBackupScopeKeepsDistinctSourceCountAndRejectsInconsistentSourceTotals() throws { + let root = try makeTemporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let destinations = [root.appendingPathComponent("A"), root.appendingPathComponent("B")] + let rows = destinations.map { ResultRow(path: root.appendingPathComponent("card/clip.mov").path, + status: "✅ Verified", size: 10, checksum: "abc", destination: $0.path) } + let report: EnhancedJSONReport = try EvidenceWriter.makeEnhancedJSONReport( + results: rows, jobID: UUID(), started: Date(), finished: Date(), kind: .copyAndVerify, + sourceURL: root.appendingPathComponent("card"), destinationURLs: destinations, + fileCount: 2, matchCount: 2, totalBytesProcessed: 20, duration: 1, workers: 1, + prefs: ReportPrefs(verificationMode: .standard), project: nil, safeToErase: true) + let data = try EvidenceWriter.encodeEnhancedJSONReport(report) + let url = root.appendingPathComponent("r.json") + let control = try #require(ReportScanner.transferCard(reportData: data, reportURL: url)) + #expect(control.verified) + #expect(control.fileCount == 1) + #expect(control.metadata?.verificationMode == .standard) + + var object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var source = try #require(object["source"] as? [String: Any]) + source["fileCount"] = 2 + object["source"] = source + let changed = try #require(ReportScanner.transferCard(reportData: JSONSerialization.data(withJSONObject: object), reportURL: url)) + #expect(changed.verified == false) } /// A Quick copy checks sizes only; its report must not read as verified. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift index 6b8dda88..0219137a 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceReader.swift @@ -173,34 +173,74 @@ public enum EvidenceReader: Sendable { name.lowercased().hasPrefix("bitmatch") } - /// Reads the verification mode the report recorded. `nil` means the report - /// does not say, and such a report is never treated as verified. - public static func verificationMode(method: String?, algorithm: String?) -> VerificationMode? { + /// Reads an explicit selected mode when present and compatible with the + /// recorded method/digests. Legacy generic checksum reports use Standard + /// as the weakest content-check classification; MD5 does not prove a + /// separate source reread. None of these fields proves completion. + public static func verificationMode(method: String?, algorithm: String?, scope: VerificationScopeEvidence? = nil) -> VerificationMode? { guard let method else { return nil } + let mode: VerificationMode switch method.lowercased() { - case "size-only", "quick": return .quick + case "size-only", "quick": mode = .quick case "checksum": guard let algorithm else { return nil } let names = algorithm.lowercased().split(separator: ",", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } guard !names.isEmpty, names.allSatisfy({ ["sha-256", "sha256", "md5", "xxh64", "xxhash64"].contains($0) }) else { return nil } - return names.contains("md5") ? .thorough : .standard - case "standard": return .standard - case "thorough": return .thorough + mode = .standard + case "standard": mode = .standard + case "thorough": mode = .thorough case "checksum-and-byte-compare", "paranoid", "byte-compare", "byte-to-byte", "byte_compare": - return .paranoid + mode = .paranoid default: return nil } + guard let scope else { return mode } + guard scope.hasConsistentRecordedCounts else { return nil } + // A selected plan cannot override incompatible recorded checks. In + // particular, a malformed Quick scope must never become verified. + let algorithms = Set((algorithm ?? "").lowercased().split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) }) + guard algorithms.isSubset(of: ["sha-256", "sha256", "md5", "xxh64", "xxhash64"]) else { return nil } + let hasSHA256 = !algorithms.isDisjoint(with: ["sha-256", "sha256"]) + switch scope.selectedMode { + case .quick: + guard mode == .quick else { return nil } + case .standard: + guard mode == .standard, hasSHA256 else { return nil } + case .thorough: + guard [.standard, .thorough].contains(mode), hasSHA256, algorithms.contains("md5") else { return nil } + case .paranoid: + guard mode == .paranoid, hasSHA256 else { return nil } + } + return scope.selectedMode + } + + /// The actual selected mode, without inventing a copy plan for a legacy + /// report that recorded only generic checksum evidence. + public static func recordedVerificationMode(method: String?, algorithm: String?, scope: VerificationScopeEvidence? = nil) -> VerificationMode? { + if scope == nil { + // Generic byte-comparison evidence also does not prove that the + // Paranoid copy plan (including SHA-256) was selected. + guard ["size-only", "quick", "standard", "thorough", "paranoid"].contains(method?.lowercased() ?? "") else { return nil } + } + return verificationMode(method: method, algorithm: algorithm, scope: scope) } /// Verified means every file matched, at least one file was checked, and /// the report says contents were compared. A Quick (size-only) copy or a /// report with no recorded method is never verified (promise 2). - public static func isVerified(matches: Int, issues: Int, mode: VerificationMode?, totalResults: Int? = nil, safeToErase: Bool? = nil) -> Bool { + public static func isVerified(matches: Int, issues: Int, mode: VerificationMode?, totalResults: Int? = nil, safeToErase: Bool? = nil, scope: VerificationScopeEvidence? = nil, sourceFileCount: Int? = nil) -> Bool { guard let mode, mode != .quick else { return false } guard safeToErase != false else { return false } if let totalResults { guard totalResults > 0, matches == totalResults else { return false } } + if let scope { + guard scope.hasConsistentRecordedCounts, scope.selectedMode == mode, + scope.recordedExcludedSourceFiles == 0, + scope.recordedVerifiedResults == matches, + scope.recordedSelectedResults == totalResults, + scope.recordedSelectedSourceFiles == sourceFileCount else { return false } + } return issues == 0 && matches > 0 } @@ -293,10 +333,12 @@ public enum EvidenceReader: Sendable { public struct Verification: Decodable, Sendable { public let method: String public let algorithm: String? + public let scope: VerificationScopeEvidence? - public init(method: String, algorithm: String?) { + public init(method: String, algorithm: String?, scope: VerificationScopeEvidence? = nil) { self.method = method self.algorithm = algorithm + self.scope = scope } } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift index 6e2d9945..dbb0da3a 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift @@ -56,4 +56,18 @@ public struct VerificationScopeEvidence: Codable, Sendable { guard recordedExcludedSourceFiles > 0 else { return nil } return "\(recordedExcludedSourceFiles) AppleDouble source files intentionally excluded; the whole card was not backed up." } + + /// Counts decoded from another report are claims, not trusted counters. + /// Compare them with that report's totals before accepting its verdict. + public var hasConsistentRecordedCounts: Bool { + countsBasis == "recorded-results-only" + && recordedSelectedSourceFiles >= 0 + && recordedSelectedResults >= 0 + && recordedVerifiedResults >= 0 + && recordedExcludedSourceFiles >= 0 + && recordedSelectedSourceFiles <= recordedSelectedResults + && (recordedSelectedSourceFiles == 0) == (recordedSelectedResults == 0) + && recordedVerifiedResults <= recordedSelectedResults + && (selectedMode != .quick || recordedVerifiedResults == 0) + } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift index 0f66b4c1..d3865deb 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift @@ -42,4 +42,70 @@ struct VerificationScopeEvidenceTests { #expect(VerificationScopeEvidence.checkPlan(for: .thorough).contains("MD5")) #expect(VerificationScopeEvidence.checkPlan(for: .paranoid).contains("byte comparison")) } + + @Test(arguments: VerificationMode.allCases) + func savedModeRoundTripsWithoutInferringItsSourceReadsFromDigests(mode: VerificationMode) throws { + let description = EvidenceWriter.verificationDescription(for: ReportPrefs(verificationMode: mode)) + let written = EnhancedJSONReport.Verification(method: description.method, algorithm: description.algorithm, + issuesByType: [:], checksumCache: nil, scope: .init(selectedMode: mode, results: [])) + let read = try JSONDecoder().decode(EvidenceReader.Snapshot.Verification.self, from: JSONEncoder().encode(written)) + #expect(read.scope?.selectedMode == mode) + #expect(EvidenceReader.verificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == mode) + } + + @Test(arguments: ["MD5", "SHA-256, MD5"]) + func legacyCompatibilityDigestsDoNotInventAThoroughSourceReread(algorithm: String) throws { + let written = EnhancedJSONReport.Verification(method: "checksum", algorithm: algorithm, + issuesByType: [:], checksumCache: nil) + let read = try JSONDecoder().decode(EvidenceReader.Snapshot.Verification.self, from: JSONEncoder().encode(written)) + #expect(read.scope == nil) + #expect(EvidenceReader.verificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == .standard) + #expect(EvidenceReader.recordedVerificationMode(method: read.method, algorithm: read.algorithm, scope: read.scope) == nil) + } + + @Test func selectedScopeCannotOverrideContradictoryOrUnsupportedRecordedChecks() { + func resolve(_ mode: VerificationMode, _ method: String, _ algorithm: String?) -> VerificationMode? { + EvidenceReader.verificationMode(method: method, algorithm: algorithm, + scope: .init(selectedMode: mode, results: [])) + } + #expect(resolve(.quick, "checksum", "SHA-256") == nil) + #expect(resolve(.standard, "size-only", nil) == nil) + #expect(resolve(.standard, "checksum", "MD5") == nil) + #expect(resolve(.thorough, "checksum", "SHA-256") == nil) + #expect(resolve(.paranoid, "checksum", "SHA-256") == nil) + #expect(resolve(.standard, "checksum", "future-hash") == nil) + #expect(resolve(.paranoid, "checksum-and-byte-compare", "SHA-256, future-hash") == nil) + #expect(resolve(.standard, "future-method", "SHA-256") == nil) + } + + @Test(arguments: ["checksum-and-byte-compare", "byte-compare", "byte-to-byte", "byte_compare"]) + func legacyByteChecksDoNotInventAParanoidCopyPlan(method: String) { + #expect(EvidenceReader.verificationMode(method: method, algorithm: nil) == .paranoid) + #expect(EvidenceReader.recordedVerificationMode(method: method, algorithm: nil) == nil) + } + + @Test(arguments: ["recordedSelectedSourceFiles", "recordedSelectedResults", "recordedVerifiedResults", "recordedExcludedSourceFiles", "countsBasis"]) + func inconsistentScopeCannotOverrideSavedVerdict(field: String) throws { + let rows = [ResultRow(path: "clip", status: "✅ Verified", size: 1, checksum: "a", destination: "Backup")] + let scope = VerificationScopeEvidence(selectedMode: .standard, results: rows) + #expect(EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope, sourceFileCount: 1)) + var object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(scope)) as? [String: Any]) + object[field] = field == "countsBasis" ? "invented-counts" : -1 + let changed = try JSONDecoder().decode(VerificationScopeEvidence.self, from: JSONSerialization.data(withJSONObject: object)) + #expect(EvidenceReader.verificationMode(method: "checksum", algorithm: "SHA-256", scope: changed) == nil) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: changed, sourceFileCount: 1)) + } + + @Test func selectedResultCountsMustAgreeWithSavedStatisticsAndExclusionsRemainUnsafe() { + let one = ResultRow(path: "clip", status: "✅ Verified", size: 1, checksum: "a", destination: "Backup") + let scope = VerificationScopeEvidence(selectedMode: .standard, results: [one]) + #expect(!EvidenceReader.isVerified(matches: 2, issues: 0, mode: .standard, totalResults: 2, safeToErase: true, scope: scope, sourceFileCount: 1)) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, safeToErase: true, scope: scope, sourceFileCount: 1)) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: false, scope: scope, sourceFileCount: 1)) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope, sourceFileCount: 2)) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: scope)) + let excluded = ResultRow(path: "._clip", status: ResultOutcome.excludedAppleDouble.statusText, size: 1, checksum: nil, destination: "Backup") + let filtered = VerificationScopeEvidence(selectedMode: .standard, results: [one, excluded]) + #expect(!EvidenceReader.isVerified(matches: 1, issues: 0, mode: .standard, totalResults: 1, safeToErase: true, scope: filtered, sourceFileCount: 2)) + } } diff --git a/Shared/Core/Models/TransferModels.swift b/Shared/Core/Models/TransferModels.swift index 34ff9d01..91e2bf83 100644 --- a/Shared/Core/Models/TransferModels.swift +++ b/Shared/Core/Models/TransferModels.swift @@ -10,7 +10,8 @@ struct TransferMetadata: Codable { let endTime: Date? let totalFiles: Int let totalSize: Int64 - let verificationMode: VerificationMode + /// Nil for older generic checksum reports that did not record a mode. + let verificationMode: VerificationMode? let cameraSettings: CameraLabelSettings? var duration: TimeInterval? { diff --git a/Shared/Core/Services/ReportScanner.swift b/Shared/Core/Services/ReportScanner.swift index e59ac4b1..9efa8a26 100644 --- a/Shared/Core/Services/ReportScanner.swift +++ b/Shared/Core/Services/ReportScanner.swift @@ -45,10 +45,14 @@ enum ReportScanner { } static func transferCard(from report: Snapshot, reportURL: URL, pdfIsValid: Bool? = nil) -> TransferCard { - let mode = EvidenceReader.verificationMode(method: report.verification?.method, algorithm: report.verification?.algorithm) + let mode = EvidenceReader.verificationMode(method: report.verification?.method, + algorithm: report.verification?.algorithm, scope: report.verification?.scope) let verified = EvidenceReader.isVerified(matches: report.statistics.matches, issues: report.statistics.issues, mode: mode, - totalResults: report.statistics.totalFiles, safeToErase: report.safeToErase) + totalResults: report.statistics.totalFiles, safeToErase: report.safeToErase, + scope: report.verification?.scope, sourceFileCount: report.source.fileCount) && (report.pdfEvidence == nil || pdfIsValid == true) + let recordedMode = EvidenceReader.recordedVerificationMode(method: report.verification?.method, + algorithm: report.verification?.algorithm, scope: report.verification?.scope) let sourceURL = URL(fileURLWithPath: report.source.path) let destinationURLs = report.destinations.map { URL(fileURLWithPath: $0.path) } let cameraName = cameraName(for: report) @@ -80,13 +84,12 @@ enum ReportScanner { endTime: finished, totalFiles: report.source.fileCount, totalSize: report.source.totalSize, - // An unrecorded mode is shown as Quick, the weakest claim. - verificationMode: mode ?? .quick, + verificationMode: recordedMode, cameraSettings: nil ) let message: String if verified { - message = "Verified" + message = recordedMode == nil ? "Content check passed · mode not recorded" : "Verified" } else if report.safeToErase == false { message = mode == .quick ? "Copied, not verified" : "Review required" } else if report.pdfEvidence != nil && pdfIsValid != true { diff --git a/Shared/Views/TransferQueueSection.swift b/Shared/Views/TransferQueueSection.swift index 5607bd9d..a1ca2834 100644 --- a/Shared/Views/TransferQueueSection.swift +++ b/Shared/Views/TransferQueueSection.swift @@ -597,6 +597,18 @@ struct TransferQueueSection: View { finishedDetails(row, outcome: outcome) } if row.isFinished, let record = coordinator.transferJournal.records.first(where: { $0.id == row.id }) { + DisclosureGroup("Verification details") { + let scope = VerificationScopeEvidence(selectedMode: record.verificationMode, + results: record.results, includeModePlan: record.plannedFileCounts != nil) + VStack(alignment: .leading, spacing: 6) { + Text(scope.selectedModeLine) + Text(scope.recordedResultsLine) + if let exclusion = scope.exclusionLine { Text(exclusion) } + } + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .font(.caption) TransferRecoveryDetails(record: record, history: coordinator.transferJournal.records) } } diff --git a/docs/VERIFICATION_ASSURANCE_AUDIT.md b/docs/VERIFICATION_ASSURANCE_AUDIT.md index 9d1ec0f6..5448bf83 100644 --- a/docs/VERIFICATION_ASSURANCE_AUDIT.md +++ b/docs/VERIFICATION_ASSURANCE_AUDIT.md @@ -43,9 +43,9 @@ The source audit found two presentation/report issues addressed in this batch: README and Guide now explain Standard's read scope and the limits of repeated source reads. This does **not** complete the TODO acceptance criterion for all surfaces. Continue on Mac with: - Setup now takes its compact description from `VerificationMode`, and Mac/iPhone/iPad settings use the same full description. Compare uses its own check plan for mode details rather than copy-mode wording. The iPhone/iPad introduction no longer implies that Quick checks file contents. Review the compact wording on narrow devices during the next signed-app walkthrough. -- Outcome and History show mode/digest labels, but do not yet communicate all mode-specific source-read limits. -- PDF/CSV/JSON primarily record method/algorithm. Add explicit scope and limits without implying that an enabled check completed in an interrupted attempt. Preserve older evidence compatibility and selected-file exclusions. -- `EvidenceReader.verificationMode` currently infers modes from legacy method/algorithm strings. An MD5 algorithm alone is not proof of a Thorough source reread. Plan explicit recorded-mode/scope metadata and conservative legacy presentation rather than inventing source assurance. +- History and PDF/CSV/JSON now distinguish the selected check plan from saved-result counts, including selected-file exclusions and older attempts without a saved plan (#27). Finish adds the same scope under **Verification details** inside an expanded transfer row; the normal row stays compact. The plan does not claim that interrupted work completed. +- The October 19 reader follow-up retains explicit selected-mode scope and checks its method, digests and counts against the saved statistics. A generic legacy checksum report can retain its recorded content-match result, but its selected mode stays unknown: MD5 does not establish Thorough or a separate source reread. Contradictory scope cannot override Quick, unknown algorithms, an unsafe verdict or mismatched counts. +- Count validation compares backup-result counts with `statistics.totalFiles` and distinct recorded-source counts with `source.fileCount`. A two-destination report retains one distinct source count. These still describe recorded rows, so the reader does not invent a complete original source plan from them. - Run the existing SFTP interruption/stale-local-evidence suites, the new report/status regressions and the signed report workflow. Independently inspect exported JSON, CSV and PDFs. See [the cloud handoff](CLOUD_RELIABILITY_HANDOFF.md) for executed checks, pending commands and issue boundaries. User reports #10 and #23 remain open: these changes do not establish their causes. From cfb6f571c1e486e0d6cdf8c808bfb5032ab4e8ea Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Mon, 19 Oct 2026 19:54:48 -0400 Subject: [PATCH 2/2] Require source analysis before testing setup readiness --- .../TestHelpers/SharedCoordinatorTestSupport.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift index 7a6794d3..53404069 100644 --- a/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift +++ b/BitMatchTests/TestHelpers/SharedCoordinatorTestSupport.swift @@ -204,7 +204,11 @@ final class SharedProjectFixture { ) fixture.coordinator.destinationURLs = [folders.primary, folders.secondary] fixture.coordinator.sourceURL = folders.source - await fixture.waitUntilSourceScanned() + guard await fixture.waitUntilSourceScanned() else { + folders.cleanup() + throw NSError(domain: "SharedProjectFixture", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Fixture source analysis did not settle before setup."]) + } if prepareCard { try fixture.prepareCard() } return fixture } @@ -236,10 +240,11 @@ final class SharedProjectFixture { @discardableResult func waitUntilSourceScanned() async -> Bool { - await waitUntil(timeout: .seconds(5)) { + await waitUntil(timeout: .seconds(30)) { coordinator.sourceFolderInfo?.url == folders.source && !coordinator.isFolderInfoLoading(for: folders.source) && !coordinator.appleDoubleSelection.isReviewing + && !coordinator.isAnalysingSource } }