krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

d0b3b7a15b59266b4ae4262fdb0364245092c17c

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-24T17:38:38Z

feat(v3.7.0): add timeline and advanced diffing system

- introduce TimelineView for historical snapshots
- allow comparison between any two snapshots
- implement DiffService for structured domain diffs
- improve diff visualization with clear change indicators
- add navigation across changes
- optimize history loading for performance
- extend CLI and export to support timeline data
 DomainDig.xcodeproj/project.pbxproj                |   8 +-
 .../xcschemes/xcschememanagement.plist             |   4 +-
 DomainDig/ContentView.swift                        |  49 +-
 DomainDig/DiffService.swift                        | 563 +++++++++++++++++++++
 DomainDig/DomainDiffService.swift                  | 555 --------------------
 DomainDig/DomainDig/DomainDebugLog.swift           |  37 ++
 DomainDig/DomainMonitoringService.swift            |   4 +
 DomainDig/DomainViewModel.swift                    | 321 +++++++++++-
 DomainDig/HistoryView.swift                        |  85 ++--
 DomainDig/Models.swift                             | 114 ++++-
 DomainDig/PortScanService.swift                    |   2 +-
 DomainDig/RDAPService.swift                        |  10 +
 DomainDig/ReachabilityService.swift                |   2 +-
 DomainDig/SubdomainDiscoveryService.swift          |  11 +
 DomainDig/TimelineView.swift                       | 224 ++++++++
 DomainDig/WatchlistView.swift                      |   3 +-
 DomainDigCLI.swift                                 | 117 +++++
 DomainInspectionService.swift                      | 103 ++--
 DomainReportBuilder.swift                          | 119 ++++-
 DomainReportExporter.swift                         |  56 ++
 LookupSnapshot.swift                               |   8 +
 21 files changed, 1701 insertions(+), 694 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 1ccc6bc..3b1e311 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -366,7 +366,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 28;
+				CURRENT_PROJECT_VERSION = 29;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.6.0;
+				MARKETING_VERSION = 3.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 28;
+				CURRENT_PROJECT_VERSION = 29;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.6.0;
+				MARKETING_VERSION = 3.7.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index 9e08675..06d0111 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,12 +7,12 @@
 		<key>DomainDig.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>0</integer>
+			<integer>1</integer>
 		</dict>
 		<key>DomainDigCLI.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>1</integer>
+			<integer>0</integer>
 		</dict>
 	</dict>
 </dict>
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 0386211..76817cd 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -37,6 +37,7 @@ struct ContentView: View {
     @State private var collapsedSections: Set<ResultSection> = [.network]
     @State private var showingCurrentDomainWorkflowSheet = false
     @State private var showingBatchWorkflowSheet = false
+    @State private var showingTimeline = false
 
     var body: some View {
         let _ = purchaseService.currentTier
@@ -83,7 +84,8 @@ struct ContentView: View {
                                 title: "Latest Changes",
                                 sections: viewModel.currentDiffSections,
                                 contextNote: viewModel.currentChangeSummary?.contextNote,
-                                showsUnchanged: false
+                                showsUnchanged: false,
+                                highlightedSectionID: nil
                             )
                             .padding(.top, appDensity.metrics.sectionSpacing)
                         }
@@ -230,6 +232,11 @@ struct ContentView: View {
                 availableDomains: viewModel.batchResults.map(\.domain)
             )
         }
+        .sheet(isPresented: $showingTimeline) {
+            NavigationStack {
+                TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
+            }
+        }
     }
 
     private var inputSection: some View {
@@ -439,6 +446,11 @@ struct ContentView: View {
                     Button("Add to workflow") {
                         showingCurrentDomainWorkflowSheet = true
                     }
+                    if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
+                        Button("Open timeline") {
+                            showingTimeline = true
+                        }
+                    }
                     if FeatureAccessService.hasAccess(to: .advancedExports) {
                         Button("Copy report JSON") {
                             guard let json = viewModel.exportJSONString() else { return }
@@ -1048,8 +1060,9 @@ struct DomainDiffView: View {
     let sections: [DomainDiffSection]
     let contextNote: String?
     let showsUnchanged: Bool
+    let highlightedSectionID: String?
 
-    @State private var collapsedSections = Set<UUID>()
+    @State private var collapsedSections = Set<String>()
     @State private var showsLowSeverity = false
 
     private var filteredSections: [DomainDiffSection] {
@@ -1064,7 +1077,7 @@ struct DomainDiffView: View {
                     }
                     return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
                 }
-                return DomainDiffSection(title: section.title, items: items)
+                return DomainDiffSection(id: section.id, title: section.title, items: items)
             }
             .filter { !$0.items.isEmpty }
     }
@@ -1104,7 +1117,7 @@ struct DomainDiffView: View {
                                             .font(.system(.caption, design: .monospaced))
                                             .foregroundStyle(.secondary)
                                         Spacer()
-                                        Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
+                                        Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))")
                                             .font(.system(.caption2, design: .monospaced))
                                             .foregroundStyle(changeColor(for: item))
                                             .padding(.horizontal, 8)
@@ -1154,9 +1167,19 @@ struct DomainDiffView: View {
                             }
                         }
                     }
+                    .id(section.id)
+                    .overlay {
+                        if highlightedSectionID == section.id {
+                            RoundedRectangle(cornerRadius: 12)
+                                .stroke(Color.cyan.opacity(0.55), lineWidth: 1)
+                        }
+                    }
                 }
             }
         }
+        .onAppear {
+            collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
+        }
     }
 
     private func changeLabel(for changeType: DiffChangeType) -> String {
@@ -2460,6 +2483,24 @@ struct SettingsView: View {
                 }
             }
 
+            Section("History") {
+                Picker(
+                    "Auto-prune",
+                    selection: Binding(
+                        get: { viewModel.historyAutoPruneOption },
+                        set: { viewModel.setHistoryAutoPruneOption($0) }
+                    )
+                ) {
+                    ForEach(HistoryAutoPruneOption.allCases) { option in
+                        Text(option.title).tag(option)
+                    }
+                }
+
+                Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+            }
+
             Section("Network") {
                 Picker("Resolver", selection: $resolverOption) {
                     ForEach(DNSResolverOption.allCases) { option in
diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift
new file mode 100644
index 0000000..948f470
--- /dev/null
+++ b/DomainDig/DiffService.swift
@@ -0,0 +1,563 @@
+import Foundation
+
+enum DiffChangeType: String, Codable {
+    case added
+    case removed
+    case changed
+    case unchanged
+
+    var marker: String {
+        switch self {
+        case .added:
+            return "+"
+        case .removed:
+            return "-"
+        case .changed:
+            return "~"
+        case .unchanged:
+            return "="
+        }
+    }
+
+    var title: String {
+        switch self {
+        case .added:
+            return "Added"
+        case .removed:
+            return "Removed"
+        case .changed:
+            return "Changed"
+        case .unchanged:
+            return "Unchanged"
+        }
+    }
+}
+
+struct DiffItem: Identifiable, Equatable, Codable {
+    let id: String
+    let label: String
+    let changeType: DiffChangeType
+    let oldValue: String?
+    let newValue: String?
+    let severity: ChangeSeverity
+
+    init(
+        id: String,
+        label: String,
+        changeType: DiffChangeType,
+        oldValue: String?,
+        newValue: String?,
+        severity: ChangeSeverity
+    ) {
+        self.id = id
+        self.label = label
+        self.changeType = changeType
+        self.oldValue = oldValue
+        self.newValue = newValue
+        self.severity = severity
+    }
+
+    var hasChanges: Bool {
+        changeType != .unchanged
+    }
+}
+
+struct DiffSection: Identifiable, Equatable, Codable {
+    let id: String
+    let title: String
+    let items: [DiffItem]
+
+    var hasChanges: Bool {
+        items.contains(where: \.hasChanges)
+    }
+
+    var severity: ChangeSeverity {
+        items.map(\.severity).max() ?? .low
+    }
+
+    var changeCount: Int {
+        items.filter(\.hasChanges).count
+    }
+}
+
+struct DomainDiff: Identifiable, Equatable, Codable {
+    let domain: String
+    let fromTimestamp: Date
+    let toTimestamp: Date
+    let sections: [DiffSection]
+    let changedSectionIDs: [String]
+    let changedSectionTitles: [String]
+    let contextNote: String?
+
+    var id: String {
+        "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
+    }
+
+    var changeCount: Int {
+        sections.reduce(0) { $0 + $1.changeCount }
+    }
+
+    var severity: ChangeSeverity {
+        sections.map(\.severity).max() ?? .low
+    }
+}
+
+typealias DomainDiffItem = DiffItem
+typealias DomainDiffSection = DiffSection
+
+enum DiffService {
+    static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
+        let sections = [
+            availabilitySection(from: oldReport, to: newReport),
+            ownershipSection(from: oldReport, to: newReport),
+            dnsSection(from: oldReport, to: newReport),
+            webSection(from: oldReport, to: newReport),
+            emailSection(from: oldReport, to: newReport),
+            networkSection(from: oldReport, to: newReport),
+            subdomainsSection(from: oldReport, to: newReport),
+            riskSection(from: oldReport, to: newReport)
+        ]
+
+        let changedSections = sections.filter(\.hasChanges)
+        return DomainDiff(
+            domain: newReport.domain,
+            fromTimestamp: oldReport.timestamp,
+            toTimestamp: newReport.timestamp,
+            sections: sections,
+            changedSectionIDs: changedSections.map(\.id),
+            changedSectionTitles: changedSections.map(\.title),
+            contextNote: comparisonContextNote(from: oldReport, to: newReport)
+        )
+    }
+
+    static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
+        let builder = DomainReportBuilder()
+        let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
+        let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
+        return compare(from: oldReport, to: newReport)
+    }
+
+    static func summary(
+        from oldSnapshot: LookupSnapshot,
+        to newSnapshot: LookupSnapshot,
+        generatedAt: Date = Date(),
+        riskAssessment: DomainRiskAssessment? = nil,
+        insights: [String]? = nil
+    ) -> DomainChangeSummary {
+        let diff = compare(from: oldSnapshot, to: newSnapshot)
+        let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
+        let highlights = diff.changedSectionTitles
+        let severity = changedItems.map(\.severity).max() ?? .low
+        let message = summaryMessage(from: highlights, changeCount: changedItems.count)
+        let observedFacts = changedItems.prefix(4).map { item in
+            "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
+        }
+
+        let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
+        let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
+        let currentInsights = insights ?? analysis.insights
+        let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
+        let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
+        let impactClassification = DomainInsightEngine.impactClassification(
+            severity: severity,
+            riskDelta: riskScoreDelta,
+            changedSections: highlights
+        )
+
+        return DomainChangeSummary(
+            hasChanges: !changedItems.isEmpty,
+            changedSections: highlights,
+            message: message,
+            severity: severity,
+            impactClassification: impactClassification,
+            generatedAt: generatedAt,
+            observedFacts: observedFacts,
+            inferredConclusions: highlights.isEmpty ? [] : [message],
+            contextNote: diff.contextNote,
+            riskAssessment: currentRiskAssessment,
+            insights: currentInsights,
+            riskScoreDelta: riskScoreDelta
+        )
+    }
+
+    static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
+        var notes: [String] = []
+        if oldReport.resolverURLString != newReport.resolverURLString {
+            notes.append("Compared snapshots used different DNS resolvers.")
+        }
+        if oldReport.resultSource != newReport.resultSource {
+            notes.append("Compared snapshots came from different collection modes.")
+        }
+        return notes.isEmpty ? nil : notes.joined(separator: " ")
+    }
+
+    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
+        comparisonContextNote(
+            from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
+            to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
+        )
+    }
+
+    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+        guard let days = snapshot.sslInfo?.daysUntilExpiry else {
+            return .none
+        }
+        if days < 14 {
+            return .critical
+        }
+        if days < 30 {
+            return .warning
+        }
+        return .none
+    }
+
+    private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "availability",
+            title: "Domain / Availability",
+            items: [
+                compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
+                compare(
+                    id: "availability",
+                    label: "Availability",
+                    oldValue: availabilityLabel(oldReport.availability),
+                    newValue: availabilityLabel(newReport.availability),
+                    severity: .high
+                ),
+                compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
+                compare(
+                    id: "tls-status",
+                    label: "TLS Status",
+                    oldValue: oldReport.web.tlsStatus,
+                    newValue: newReport.web.tlsStatus,
+                    severity: .medium
+                )
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "ownership",
+            title: "Ownership",
+            items: [
+                compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
+                compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
+                compare(
+                    id: "ownership-created",
+                    label: "Registration Date",
+                    oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
+                    newValue: ownershipDateLabel(newReport.ownership?.createdDate),
+                    severity: .low
+                ),
+                compare(
+                    id: "ownership-expires",
+                    label: "Expiration Date",
+                    oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
+                    newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
+                    severity: .medium
+                ),
+                compare(
+                    id: "ownership-status",
+                    label: "Status",
+                    oldValue: joined(oldReport.ownership?.status),
+                    newValue: joined(newReport.ownership?.status),
+                    severity: .low
+                ),
+                compare(
+                    id: "ownership-nameservers",
+                    label: "Nameservers",
+                    oldValue: joined(oldReport.ownership?.nameservers),
+                    newValue: joined(newReport.ownership?.nameservers),
+                    severity: .medium
+                ),
+                compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
+        let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
+        let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
+
+        var items: [DiffItem] = [
+            compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
+            compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
+        ].compactMap { $0 }
+
+        for type in recordTypes {
+            items.append(
+                compare(
+                    id: "dns-\(type.rawValue.lowercased())-records",
+                    label: "\(type.rawValue) Records",
+                    oldValue: normalizedRecordValues(for: oldSections[type]),
+                    newValue: normalizedRecordValues(for: newSections[type]),
+                    severity: type == .A || type == .NS ? .high : .medium
+                ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
+            )
+            if let ttlChange = compare(
+                id: "dns-\(type.rawValue.lowercased())-ttl",
+                label: "\(type.rawValue) TTL",
+                oldValue: normalizedTTLValues(for: oldSections[type]),
+                newValue: normalizedTTLValues(for: newSections[type]),
+                severity: .low
+            ) {
+                items.append(ttlChange)
+            }
+        }
+
+        return DiffSection(
+            id: "dns",
+            title: "DNS",
+            items: items.filter { !$0.id.isEmpty }
+        )
+    }
+
+    private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "web",
+            title: "Web",
+            items: [
+                compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
+                compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
+                compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
+                compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
+                compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
+                compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
+                compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "email",
+            title: "Email Security",
+            items: [
+                compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
+                compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
+                compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
+                compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
+                compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
+                compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
+                compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "network",
+            title: "Network",
+            items: [
+                compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
+                compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
+                compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high),
+                compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "subdomains",
+            title: "Subdomains",
+            items: [
+                compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
+                compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
+                compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "risk",
+            title: "Risk / Insights",
+            items: [
+                compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
+                compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
+                compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
+                compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
+            ].compactMap { $0 }
+        )
+    }
+
+    private static func compare(
+        id: String,
+        label: String,
+        oldValue: String?,
+        newValue: String?,
+        severity: ChangeSeverity
+    ) -> DiffItem? {
+        let oldValue = normalized(oldValue)
+        let newValue = normalized(newValue)
+
+        guard oldValue != nil || newValue != nil else {
+            return nil
+        }
+
+        let changeType: DiffChangeType
+        switch (oldValue?.lowercased(), newValue?.lowercased()) {
+        case let (old?, new?) where old == new:
+            changeType = .unchanged
+        case (nil, _?):
+            changeType = .added
+        case (_?, nil):
+            changeType = .removed
+        default:
+            changeType = .changed
+        }
+
+        return DiffItem(
+            id: id,
+            label: label,
+            changeType: changeType,
+            oldValue: oldValue,
+            newValue: newValue,
+            severity: severity
+        )
+    }
+
+    static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
+        guard !sectionTitles.isEmpty else {
+            return "No meaningful changes"
+        }
+        if sectionTitles.count == 1 {
+            return "\(sectionTitles[0]) changed"
+        }
+        return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
+    }
+
+    private static func normalized(_ value: String?) -> String? {
+        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+            return nil
+        }
+        return value
+    }
+
+    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
+        switch status {
+        case .available:
+            return "Available"
+        case .registered:
+            return "Registered"
+        case .unknown:
+            return "Unknown"
+        }
+    }
+
+    private static func ownershipDateLabel(_ date: Date?) -> String? {
+        date?.formatted(date: .abbreviated, time: .omitted)
+    }
+
+    private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
+        guard let certificate else { return nil }
+        return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
+    }
+
+    private static func joined(_ values: [String]?) -> String? {
+        guard let values else { return nil }
+        let normalizedValues = values
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+            .sorted()
+        return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
+    }
+
+    private static func normalizedRecordValues(for section: DNSSection?) -> String? {
+        guard let section else { return nil }
+        let values = (section.records + section.wildcardRecords)
+            .map(\.value)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: ", ")
+    }
+
+    private static func normalizedTTLValues(for section: DNSSection?) -> String? {
+        guard let section else { return nil }
+        let values = (section.records + section.wildcardRecords)
+            .map { "\($0.value.lowercased()):\($0.ttl)" }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: ", ")
+    }
+
+    private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
+        let values = headers
+            .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: " | ")
+    }
+
+    private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
+        let values = redirects.map { "\($0.statusCode) \($0.url)" }
+        return values.isEmpty ? nil : values.joined(separator: " -> ")
+    }
+
+    private static func portScanSummary(_ results: [PortScanResult]) -> String? {
+        let values = results
+            .sorted { $0.port < $1.port }
+            .map { "\($0.port):\($0.open ? "open" : "closed")" }
+        return values.isEmpty ? nil : values.joined(separator: ", ")
+    }
+
+    private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
+        joined(groups.map { "\($0.label): \($0.subdomains.count)" })
+    }
+
+    private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
+        guard let record else { return nil }
+        if record.found {
+            return record.value ?? "Present"
+        }
+        return "Missing"
+    }
+
+    private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
+        guard let result else { return nil }
+        guard result.txtFound else { return "Missing" }
+        return result.policyMode ?? "Present"
+    }
+
+    private static func dnssecLabel(_ value: Bool?) -> String? {
+        switch value {
+        case true:
+            return "Signed"
+        case false:
+            return "Unsigned"
+        case nil:
+            return nil
+        }
+    }
+}
+
+enum DomainDiffService {
+    static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
+        DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
+    }
+
+    static func summary(
+        from oldSnapshot: LookupSnapshot,
+        to newSnapshot: LookupSnapshot,
+        generatedAt: Date = Date(),
+        riskAssessment: DomainRiskAssessment? = nil,
+        insights: [String]? = nil
+    ) -> DomainChangeSummary {
+        DiffService.summary(
+            from: oldSnapshot,
+            to: newSnapshot,
+            generatedAt: generatedAt,
+            riskAssessment: riskAssessment,
+            insights: insights
+        )
+    }
+
+    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
+        DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
+    }
+
+    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+        DiffService.certificateWarningLevel(for: snapshot)
+    }
+}
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
deleted file mode 100644
index 0d8795a..0000000
--- a/DomainDig/DomainDiffService.swift
+++ /dev/null
@@ -1,555 +0,0 @@
-import Foundation
-
-enum DiffChangeType: String, Codable {
-    case added
-    case removed
-    case changed
-    case unchanged
-}
-
-struct DomainDiffItem: Identifiable, Equatable {
-    let id = UUID()
-    let label: String
-    let changeType: DiffChangeType
-    let oldValue: String?
-    let newValue: String?
-    let severity: ChangeSeverity
-
-    var hasChanges: Bool {
-        changeType != .unchanged
-    }
-
-    var isMeaningful: Bool {
-        hasChanges && severity >= .medium
-    }
-}
-
-struct DomainDiffSection: Identifiable, Equatable {
-    let id = UUID()
-    let title: String
-    let items: [DomainDiffItem]
-
-    var hasChanges: Bool {
-        items.contains(where: \.hasChanges)
-    }
-
-    var severity: ChangeSeverity {
-        items.map(\.severity).max() ?? .low
-    }
-}
-
-enum DomainDiffService {
-    static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
-        [
-            availabilitySection(from: oldSnapshot, to: newSnapshot),
-            primaryIPSection(from: oldSnapshot, to: newSnapshot),
-            ownershipSection(from: oldSnapshot, to: newSnapshot),
-            dnsSection(from: oldSnapshot, to: newSnapshot),
-            redirectSection(from: oldSnapshot, to: newSnapshot),
-            tlsSection(from: oldSnapshot, to: newSnapshot),
-            httpSection(from: oldSnapshot, to: newSnapshot),
-            emailSection(from: oldSnapshot, to: newSnapshot),
-            subdomainSection(from: oldSnapshot, to: newSnapshot)
-        ]
-        .filter { !$0.items.isEmpty }
-    }
-
-    static func summary(
-        from oldSnapshot: LookupSnapshot,
-        to newSnapshot: LookupSnapshot,
-        generatedAt: Date = Date(),
-        riskAssessment: DomainRiskAssessment? = nil,
-        insights: [String]? = nil
-    ) -> DomainChangeSummary {
-        let sections = diff(from: oldSnapshot, to: newSnapshot)
-        let allChangedItems = sections
-            .flatMap(\.items)
-            .filter(\.hasChanges)
-
-        let highlights = summaryHighlights(from: allChangedItems)
-        let severity = allChangedItems.map(\.severity).max() ?? .low
-        let message = summaryMessage(from: allChangedItems, highlights: highlights)
-        let observedFacts = observedFacts(from: allChangedItems)
-        let inferredConclusions = highlights.isEmpty ? [] : [message]
-        let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot)
-        let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
-        let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment
-        let currentInsights = insights ?? newAnalysis.insights
-        let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment
-        let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score
-        let impactClassification = DomainInsightEngine.impactClassification(
-            severity: severity,
-            riskDelta: riskScoreDelta,
-            changedSections: highlights
-        )
-
-        return DomainChangeSummary(
-            hasChanges: !allChangedItems.isEmpty,
-            changedSections: highlights,
-            message: message,
-            severity: severity,
-            impactClassification: impactClassification,
-            generatedAt: generatedAt,
-            observedFacts: observedFacts,
-            inferredConclusions: inferredConclusions,
-            contextNote: contextNote,
-            riskAssessment: currentRiskAssessment,
-            insights: currentInsights,
-            riskScoreDelta: riskScoreDelta
-        )
-    }
-
-    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
-        var notes: [String] = []
-        if oldSnapshot.resolverURLString != newSnapshot.resolverURLString {
-            notes.append("Compared snapshots used different DNS resolvers.")
-        }
-        if oldSnapshot.resultSource != newSnapshot.resultSource {
-            notes.append("Compared snapshots came from different collection modes.")
-        }
-        return notes.isEmpty ? nil : notes.joined(separator: " ")
-    }
-
-    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
-        guard let days = snapshot.sslInfo?.daysUntilExpiry else {
-            return .none
-        }
-        if days < 14 {
-            return .critical
-        }
-        if days < 30 {
-            return .warning
-        }
-        return .none
-    }
-
-    private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Availability",
-            items: [
-                compare(
-                    label: "Availability",
-                    oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
-                    newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
-                    severity: .high
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Primary IP",
-            items: [
-                compare(
-                    label: "Primary IP",
-                    oldValue: primaryIP(from: oldSnapshot),
-                    newValue: primaryIP(from: newSnapshot),
-                    severity: .high
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
-        let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
-        let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
-
-        var items: [DomainDiffItem] = []
-        for type in types {
-            let oldSection = oldSections[type]
-            let newSection = newSections[type]
-
-            if let recordChange = compare(
-                label: "\(type.rawValue) Records",
-                oldValue: normalizedRecordValues(for: oldSection),
-                newValue: normalizedRecordValues(for: newSection),
-                severity: .medium
-            ) {
-                items.append(recordChange)
-            }
-
-            if let ttlChange = compare(
-                label: "\(type.rawValue) TTL",
-                oldValue: normalizedTTLValues(for: oldSection),
-                newValue: normalizedTTLValues(for: newSection),
-                severity: .low
-            ), let oldSection, let newSection,
-               normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
-                items.append(ttlChange)
-            }
-        }
-
-        return DomainDiffSection(title: "DNS", items: items)
-    }
-
-    private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Ownership",
-            items: [
-                compare(
-                    label: "Registrar",
-                    oldValue: normalized(oldSnapshot.ownership?.registrar),
-                    newValue: normalized(newSnapshot.ownership?.registrar),
-                    severity: .high
-                ),
-                compare(
-                    label: "Registration Date",
-                    oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
-                    newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
-                    severity: .low
-                ),
-                compare(
-                    label: "Expiration Date",
-                    oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
-                    newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
-                    severity: .low
-                ),
-                compare(
-                    label: "Ownership Status",
-                    oldValue: ownershipList(oldSnapshot.ownership?.status),
-                    newValue: ownershipList(newSnapshot.ownership?.status),
-                    severity: .low
-                ),
-                compare(
-                    label: "Nameservers",
-                    oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
-                    newValue: ownershipList(newSnapshot.ownership?.nameservers),
-                    severity: .medium
-                ),
-                compare(
-                    label: "Abuse Contact",
-                    oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
-                    newValue: normalized(newSnapshot.ownership?.abuseEmail),
-                    severity: .low
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Redirect",
-            items: [
-                compare(
-                    label: "Redirect Target",
-                    oldValue: finalRedirectURL(from: oldSnapshot),
-                    newValue: finalRedirectURL(from: newSnapshot),
-                    severity: .high
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        var items: [DomainDiffItem] = []
-
-        if let issuerChange = compare(
-            label: "TLS Issuer",
-            oldValue: normalized(oldSnapshot.sslInfo?.issuer),
-            newValue: normalized(newSnapshot.sslInfo?.issuer),
-            severity: .medium
-        ) {
-            items.append(issuerChange)
-        }
-
-        if let expiryChange = compare(
-            label: "TLS Expiration",
-            oldValue: expirationLabel(oldSnapshot.sslInfo),
-            newValue: expirationLabel(newSnapshot.sslInfo),
-            severity: .medium
-        ) {
-            items.append(expiryChange)
-        }
-
-        let oldWarning = certificateWarningLevel(for: oldSnapshot)
-        let newWarning = certificateWarningLevel(for: newSnapshot)
-        if oldWarning != newWarning, newWarning != .none {
-            let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
-            items.append(
-                DomainDiffItem(
-                    label: "Certificate Warning",
-                    changeType: .changed,
-                    oldValue: oldWarning.title,
-                    newValue: "Certificate expires in \(days) days",
-                    severity: newWarning == .critical ? .high : .medium
-                )
-            )
-        }
-
-        return DomainDiffSection(title: "TLS", items: items)
-    }
-
-    private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        var items: [DomainDiffItem] = []
-
-        if let statusChange = compare(
-            label: "HTTP Status",
-            oldValue: httpStatusSummary(from: oldSnapshot),
-            newValue: httpStatusSummary(from: newSnapshot),
-            severity: .medium
-        ) {
-            items.append(statusChange)
-        }
-
-        if let gradeChange = compare(
-            label: "Security Grade",
-            oldValue: normalized(oldSnapshot.httpSecurityGrade),
-            newValue: normalized(newSnapshot.httpSecurityGrade),
-            severity: .low
-        ) {
-            items.append(gradeChange)
-        }
-
-        if let headerChange = compare(
-            label: "Headers",
-            oldValue: normalizedHeaders(from: oldSnapshot),
-            newValue: normalizedHeaders(from: newSnapshot),
-            severity: .low
-        ) {
-            items.append(headerChange)
-        }
-
-        return DomainDiffSection(title: "HTTP", items: items)
-    }
-
-    private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Email Security",
-            items: [
-                compare(
-                    label: "Email Security",
-                    oldValue: normalized(emailSummary(from: oldSnapshot)),
-                    newValue: normalized(emailSummary(from: newSnapshot)),
-                    severity: .medium
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
-        DomainDiffSection(
-            title: "Subdomains",
-            items: [
-                compare(
-                    label: "Passive Subdomains",
-                    oldValue: subdomainList(from: oldSnapshot),
-                    newValue: subdomainList(from: newSnapshot),
-                    severity: .low
-                )
-            ].compactMap { $0 }
-        )
-    }
-
-    private static func compare(
-        label: String,
-        oldValue: String?,
-        newValue: String?,
-        severity: ChangeSeverity
-    ) -> DomainDiffItem? {
-        let oldValue = normalized(oldValue)
-        let newValue = normalized(newValue)
-        let normalizedOldValue = comparisonValue(oldValue)
-        let normalizedNewValue = comparisonValue(newValue)
-
-        guard oldValue != nil || newValue != nil else {
-            return nil
-        }
-
-        let changeType: DiffChangeType
-        switch (normalizedOldValue, normalizedNewValue) {
-        case let (old?, new?) where old == new:
-            changeType = .unchanged
-        case (nil, _?):
-            changeType = .added
-        case (_?, nil):
-            changeType = .removed
-        default:
-            changeType = .changed
-        }
-
-        return DomainDiffItem(
-            label: label,
-            changeType: changeType,
-            oldValue: oldValue,
-            newValue: newValue,
-            severity: severity
-        )
-    }
-
-    private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
-        var highlights: [String] = []
-
-        let labels = Set(items.map(\.label))
-        if labels.contains("Availability") {
-            highlights.append("Availability changed")
-        }
-        if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
-            highlights.append("IP changed")
-            highlights.append("DNS changed")
-            return highlights
-        }
-        if labels.contains("Primary IP") {
-            highlights.append("IP changed")
-        }
-        if labels.contains("Redirect Target") {
-            highlights.append("Redirect target changed")
-        }
-        if labels.contains("Registrar") {
-            highlights.append("Registrar changed")
-        } else if labels.contains("Nameservers") {
-            highlights.append("Nameservers changed")
-        } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
-            highlights.append("Ownership metadata changed")
-        }
-        if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
-           let message = certificateItem.newValue {
-            highlights.append(message)
-        } else if labels.contains("TLS Issuer") {
-            highlights.append("TLS issuer changed")
-        } else if labels.contains("TLS Expiration") {
-            highlights.append("Certificate expiration changed")
-        }
-        if labels.contains(where: { $0.hasSuffix("Records") }) {
-            highlights.append("DNS changed")
-        }
-        if labels.contains("HTTP Status") {
-            highlights.append("HTTP status changed")
-        }
-        if labels.contains("Email Security") {
-            highlights.append("Email security changed")
-        }
-        if labels.contains("Passive Subdomains") {
-            highlights.append("Subdomains changed")
-        }
-
-        var deduplicated: [String] = []
-        for highlight in highlights where !deduplicated.contains(highlight) {
-            deduplicated.append(highlight)
-        }
-        return deduplicated
-    }
-
-    private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
-        guard !items.isEmpty, !highlights.isEmpty else {
-            return "No meaningful changes"
-        }
-
-        if highlights.count == 1 {
-            return highlights[0]
-        }
-
-        return "\(highlights[0]) and \(highlights[1].lowercased())"
-    }
-
-    private static func observedFacts(from items: [DomainDiffItem]) -> [String] {
-        items.prefix(3).map { item in
-            let oldValue = item.oldValue ?? "none"
-            let newValue = item.newValue ?? "none"
-            return "\(item.label): \(oldValue) -> \(newValue)"
-        }
-    }
-
-    private static func normalized(_ value: String?) -> String? {
-        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
-            return nil
-        }
-        return value
-    }
-
-    private static func comparisonValue(_ value: String?) -> String? {
-        value?.lowercased()
-    }
-
-    private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
-        switch status {
-        case .available:
-            return "available"
-        case .registered:
-            return "registered"
-        case .unknown:
-            return "unknown"
-        case .none:
-            return nil
-        }
-    }
-
-    private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
-        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
-    }
-
-    private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
-        snapshot.redirectChain.last?.url
-    }
-
-    private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
-        guard let sslInfo else { return nil }
-        return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
-    }
-
-    private static func ownershipDateLabel(_ date: Date?) -> String? {
-        date?.formatted(date: .abbreviated, time: .omitted)
-    }
-
-    private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
-        if let httpStatusCode = snapshot.httpStatusCode {
-            return "\(httpStatusCode)"
-        }
-        return snapshot.httpHeadersError
-    }
-
-    private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
-        if let emailSecurity = snapshot.emailSecurity {
-            return [
-                "spf:\(emailSecurity.spf.found)",
-                "dmarc:\(emailSecurity.dmarc.found)",
-                "dkim:\(emailSecurity.dkim.found)",
-                "bimi:\(emailSecurity.bimi.found)",
-                "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
-            ].joined(separator: "|")
-        }
-        return snapshot.emailSecurityError
-    }
-
-    private static func normalizedRecordValues(for section: DNSSection?) -> String? {
-        guard let section else { return nil }
-        let values = (section.records + section.wildcardRecords)
-            .map(\.value)
-            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
-            .sorted()
-        return values.isEmpty ? nil : values.joined(separator: ",")
-    }
-
-    private static func normalizedTTLValues(for section: DNSSection?) -> String? {
-        guard let section else { return nil }
-        let values = (section.records + section.wildcardRecords)
-            .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
-            .sorted()
-        return values.isEmpty ? nil : values.joined(separator: ",")
-    }
-
-    private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
-        let headers = snapshot.httpHeaders
-            .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
-            .sorted()
-        return headers.isEmpty ? nil : headers.joined(separator: "|")
-    }
-
-    private static func ownershipList(_ values: [String]?) -> String? {
-        guard let values else { return nil }
-        let normalizedValues = values
-            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
-            .filter { !$0.isEmpty }
-            .sorted()
-        return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
-    }
-
-    private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
-        let values = snapshot.subdomains
-            .map(\.hostname)
-            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
-            .sorted()
-        return values.isEmpty ? nil : values.joined(separator: ",")
-    }
-}
diff --git a/DomainDig/DomainDig/DomainDebugLog.swift b/DomainDig/DomainDig/DomainDebugLog.swift
new file mode 100644
index 0000000..12a6cc5
--- /dev/null
+++ b/DomainDig/DomainDig/DomainDebugLog.swift
@@ -0,0 +1,37 @@
+import Foundation
+import os
+
+enum DomainDebugLog {
+    static let enabled = true
+    private static let logger = Logger(subsystem: "co.zerolabs.domain-dig", category: "Debug")
+
+    static func debug(_ message: String) {
+        guard enabled else { return }
+        logger.debug("\(message, privacy: .public)")
+    }
+
+    static func error(_ message: String) {
+        guard enabled else { return }
+        logger.error("\(message, privacy: .public)")
+    }
+
+    static func signpostStart(_ scope: String, domain: String? = nil) -> CFAbsoluteTime {
+        let start = CFAbsoluteTimeGetCurrent()
+        if let domain {
+            debug("[START] \(scope) domain=\(domain)")
+        } else {
+            debug("[START] \(scope)")
+        }
+        return start
+    }
+
+    static func signpostEnd(_ scope: String, start: CFAbsoluteTime, domain: String? = nil, extra: String? = nil) {
+        let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
+        let suffix = extra.map { " \($0)" } ?? ""
+        if let domain {
+            debug("[END] \(scope) domain=\(domain) elapsedMs=\(elapsedMs)\(suffix)")
+        } else {
+            debug("[END] \(scope) elapsedMs=\(elapsedMs)\(suffix)")
+        }
+    }
+}
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 5128c0b..56dfe3f 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -509,6 +509,10 @@ final class DomainMonitoringService {
             isPartialSnapshot: previousSnapshot.isPartialSnapshot,
             validationIssues: previousSnapshot.validationIssues,
             totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+            snapshotIndex: previousSnapshot.snapshotIndex,
+            previousSnapshotID: previousSnapshot.previousSnapshotID,
+            changeCount: previousSnapshot.changeCount,
+            severitySummary: previousSnapshot.severitySummary,
             dnsSections: previousSnapshot.dnsSections,
             dnsError: previousSnapshot.dnsError,
             availabilityResult: previousSnapshot.availabilityResult,
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 02e09f7..656529c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -208,6 +208,10 @@ final class DomainViewModel {
     private var lastBatchStartedAt: Date?
     private var activeWorkflowRunID: UUID?
     private var activeWorkflowRunName: String?
+    private var historyPersistenceSuspended = false
+    private var trackedDomainsPersistenceSuspended = false
+    private var historyPersistenceDirty = false
+    private var trackedDomainsPersistenceDirty = false
     private let reportBuilder = DomainReportBuilder()
     private let inspectionService = DomainInspectionService()
     private(set) var currentResultSource: LookupResultSource = .live
@@ -237,6 +241,8 @@ final class DomainViewModel {
     var historyDateFilter: HistoryDateFilter = .all
     var historyChangeFilter: ChangeFilterOption = .all
     var historySortOption: HistorySortOption = .newest
+    var timelineGrouping: TimelineGroupingOption = .relativeDay
+    var timelineDomainFilter = ""
     var watchlistSearchText = ""
     var watchlistFilter: WatchlistFilterOption = .all
     var watchlistSortOption: WatchlistSortOption = .pinned
@@ -249,6 +255,12 @@ final class DomainViewModel {
     var portabilityStatusMessage: String?
     var upgradePrompt: UpgradePromptContext?
     var isPaywallPresented = false
+    var selectedSnapshotIDs = Set<UUID>()
+    var activeDomainDiff: DomainDiff?
+    var activeDiffChangeIndex = 0
+
+    private static let historyAutoPruneKey = "historyAutoPrune"
+    var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption()
 
     var trimmedDomain: String {
         domain
@@ -365,6 +377,15 @@ final class DomainViewModel {
         return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
     }
 
+    var timelineDomains: [String] {
+        let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
+        let domains = history.map(\.domain)
+        let filtered = query.isEmpty
+            ? domains
+            : domains.filter { $0.localizedCaseInsensitiveContains(query) }
+        return Array(Set(filtered)).sorted()
+    }
+
     var batchProgressLabel: String {
         guard batchTotalCount > 0 else { return "No active batch" }
         if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
@@ -441,6 +462,10 @@ final class DomainViewModel {
             isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false,
             validationIssues: currentHistoryEntry?.validationIssues ?? [],
             totalLookupDurationMs: lastLookupDurationMs,
+            snapshotIndex: currentHistoryEntry?.snapshotIndex,
+            previousSnapshotID: currentHistoryEntry?.previousSnapshotID,
+            changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0,
+            severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity,
             dnsSections: dnsSections,
             dnsError: dnsError,
             availabilityResult: availabilityResult,
@@ -1372,6 +1397,22 @@ final class DomainViewModel {
         )
     }
 
+    func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
+        DomainReportExporter.timelineText(
+            for: timelineReports(for: domain),
+            domain: domain,
+            includeDiffSummary: includeDiffSummary
+        )
+    }
+
+    func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
+        try? DomainReportExporter.timelineData(
+            for: timelineReports(for: domain),
+            domain: domain,
+            includeDiffSummary: includeDiffSummary
+        )
+    }
+
     func exportFullBackupData() -> Data? {
         try? DomainDataPortabilityService.backupData()
     }
@@ -1461,20 +1502,25 @@ final class DomainViewModel {
     }
 
     private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
+        let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
         let previous = previousSnapshot(
             for: domain,
             trackedDomainID: currentTrackedDomain?.id,
             replacingLatest: false
         )
         let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
+        DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)")
         guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
 
         let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
+        let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain)
         applySnapshot(snapshot)
+        DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain)
         lastLookupDurationMs = snapshot.totalLookupDurationMs
         refreshingTrackedDomainID = nil
 
         if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
+            DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)")
             await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
         }
 
@@ -1483,8 +1529,11 @@ final class DomainViewModel {
             return history.first(where: { $0.id == snapshot.historyEntryID })
         }
 
-        let entry = saveHistoryEntry(replaceLatest: false)
+        let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
+        let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
+        DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
         await refreshUsageCredits()
+        DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
         return entry
     }
 
@@ -1496,6 +1545,7 @@ final class DomainViewModel {
         currentStatusMessage = snapshot.statusMessage
         currentDiffSections = []
         ownershipDiff = []
+        let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain)
         currentReport = reportBuilder.build(
             from: snapshot,
             previousSnapshot: previousSnapshot(
@@ -1504,6 +1554,7 @@ final class DomainViewModel {
                 replacingLatest: false
             )
         )
+        DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
         currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
 
         dnsSections = snapshot.dnsSections
@@ -1596,6 +1647,10 @@ final class DomainViewModel {
             isPartialSnapshot: previousSnapshot.isPartialSnapshot,
             validationIssues: previousSnapshot.validationIssues,
             totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+            snapshotIndex: previousSnapshot.snapshotIndex,
+            previousSnapshotID: previousSnapshot.previousSnapshotID,
+            changeCount: previousSnapshot.changeCount,
+            severitySummary: previousSnapshot.severitySummary,
             dnsSections: previousSnapshot.dnsSections,
             dnsError: previousSnapshot.dnsError,
             availabilityResult: previousSnapshot.availabilityResult,
@@ -1956,32 +2011,61 @@ final class DomainViewModel {
     }
 
     @discardableResult
-    private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
+    private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? {
         guard !searchedDomain.isEmpty else { return nil }
-        return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
+        return saveHistoryEntry(
+            from: currentSnapshot,
+            replaceLatest: replaceLatest,
+            updateCurrentState: true,
+            reuseCurrentAnalysis: reuseCurrentAnalysis
+        )
     }
 
     @discardableResult
-    private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
+    private func saveHistoryEntry(
+        from snapshot: LookupSnapshot,
+        replaceLatest: Bool,
+        updateCurrentState: Bool,
+        reuseCurrentAnalysis: Bool = false
+    ) -> HistoryEntry? {
         let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
         let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
-        let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
-        let changeSummary = previousSnapshot.map {
-            DomainDiffService.summary(
-                from: $0,
-                to: snapshot,
-                generatedAt: snapshot.timestamp,
-                riskAssessment: analysis.riskAssessment,
-                insights: analysis.insights
-            )
-        }
-        let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+        let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
+        let changeSummary = reuseCurrentAnalysis
+            ? currentChangeSummary ?? snapshot.changeSummary
+            : previousSnapshot.map {
+                DiffService.summary(
+                    from: $0,
+                    to: snapshot,
+                    generatedAt: snapshot.timestamp,
+                    riskAssessment: analysis?.riskAssessment,
+                    insights: analysis?.insights
+                )
+            }
+        let domainDiff = reuseCurrentAnalysis
+            ? previousSnapshot.map {
+                DomainDiff(
+                    domain: snapshot.domain,
+                    fromTimestamp: $0.timestamp,
+                    toTimestamp: snapshot.timestamp,
+                    sections: currentDiffSections,
+                    changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id),
+                    changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title),
+                    contextNote: currentChangeSummary?.contextNote
+                )
+            }
+            : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) }
+        let diffSections = domainDiff?.sections ?? currentDiffSections
+        let previousSnapshotID = previousSnapshot?.historyEntryID
+        let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID)
 
         if updateCurrentState {
             currentChangeSummary = changeSummary
             currentDiffSections = diffSections
             ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
-            currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
+            if !reuseCurrentAnalysis {
+                currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
+            }
         }
 
         let entry = HistoryEntry(
@@ -2029,6 +2113,10 @@ final class DomainViewModel {
             emailSecuritySummary: Self.emailSummary(from: snapshot),
             httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
             changeSummary: changeSummary,
+            snapshotIndex: nextSnapshotIndex,
+            previousSnapshotID: previousSnapshotID,
+            changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
+            severitySummary: changeSummary?.severity,
             sslError: snapshot.sslError,
             httpHeadersError: snapshot.httpHeadersError,
             reachabilityError: snapshot.reachabilityError,
@@ -2053,9 +2141,7 @@ final class DomainViewModel {
             history[0] = entry
         } else {
             history.insert(entry, at: 0)
-            if history.count > Self.maxHistory {
-                history = Array(history.prefix(Self.maxHistory))
-            }
+            trimHistoryToLimit()
         }
 
         updateTrackedDomainSnapshotMetadata(
@@ -2074,8 +2160,21 @@ final class DomainViewModel {
     }
 
     private func persistHistory() {
+        if historyPersistenceSuspended {
+            historyPersistenceDirty = true
+            return
+        }
+        let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
         DomainDataPortabilityService.saveHistoryEntries(history)
         refreshDataLifecycleSummary()
+        DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
+    }
+
+    func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
+        historyAutoPruneOption = option
+        UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
+        trimHistoryToLimit()
+        persistHistory()
     }
 
     func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -2085,11 +2184,56 @@ final class DomainViewModel {
     }
 
     private func persistTrackedDomains() {
+        if trackedDomainsPersistenceSuspended {
+            trackedDomainsPersistenceDirty = true
+            return
+        }
         DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
         CloudSyncService.shared.scheduleSyncIfNeeded()
         refreshDataLifecycleSummary()
     }
 
+    private func beginBulkPersistenceDeferral() {
+        historyPersistenceSuspended = true
+        trackedDomainsPersistenceSuspended = true
+        historyPersistenceDirty = false
+        trackedDomainsPersistenceDirty = false
+    }
+
+    private func endBulkPersistenceDeferral() {
+        historyPersistenceSuspended = false
+        trackedDomainsPersistenceSuspended = false
+
+        if trackedDomainsPersistenceDirty {
+            trackedDomainsPersistenceDirty = false
+            DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
+            CloudSyncService.shared.scheduleSyncIfNeeded()
+        }
+
+        if historyPersistenceDirty {
+            historyPersistenceDirty = false
+            DomainDataPortabilityService.saveHistoryEntries(history)
+        }
+
+        refreshDataLifecycleSummary()
+    }
+
+    private func trimHistoryToLimit() {
+        let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
+        history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
+    }
+
+    private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
+        let siblings = history.filter { entry in
+            if let trackedDomainID {
+                return entry.trackedDomainID == trackedDomainID
+            }
+            return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
+        }
+        let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
+        return existingMax + 1
+    }
+
     private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
         monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
         MonitoringStorage.saveSettings(monitoringSettings)
@@ -2328,6 +2472,7 @@ final class DomainViewModel {
     private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
         let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
         var nextIndex = 0
+        beginBulkPersistenceDeferral()
 
         await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
             for _ in 0..<concurrencyLimit {
@@ -2348,6 +2493,7 @@ final class DomainViewModel {
             }
         }
 
+        endBulkPersistenceDeferral()
         finishBatchLookup(source: source)
     }
 
@@ -2396,7 +2542,7 @@ final class DomainViewModel {
             }
         }
         let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
-        let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
+        let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
         let quickStatus: String
         if entry?.changeSummary?.hasChanges == true {
             quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
@@ -2646,15 +2792,105 @@ final class DomainViewModel {
     }
 
     func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
-        let siblings = history.filter { candidate in
-            if let trackedDomainID = entry.trackedDomainID {
-                return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
+        previousHistoryEntry(for: entry)?.snapshot
+    }
+
+    func timelineEntries(for domain: String) -> [SnapshotSummary] {
+        historyEntries(for: domain).map(\.snapshotSummary)
+    }
+
+    func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
+        let entries = timelineEntries(for: domain)
+        let grouping = grouping ?? timelineGrouping
+
+        guard grouping == .relativeDay else {
+            return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
+        }
+
+        let calendar = Calendar.current
+        let today = Date()
+        let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
+        let grouped = Dictionary(grouping: entries) { entry -> String in
+            if calendar.isDate(entry.timestamp, inSameDayAs: today) {
+                return "Today"
             }
-            return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
+            if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
+                return "Yesterday"
+            }
+            return "Older"
+        }
+
+        return ["Today", "Yesterday", "Older"].compactMap { title in
+            guard let items = grouped[title], !items.isEmpty else { return nil }
+            return TimelineSection(id: title.lowercased(), title: title, entries: items)
+        }
+    }
+
+    func historyEntries(for domain: String) -> [HistoryEntry] {
+        history
+            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+            .sorted { $0.timestamp > $1.timestamp }
+    }
+
+    func historyEntry(withID id: UUID) -> HistoryEntry? {
+        history.first(where: { $0.id == id })
+    }
+
+    func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
+        let siblings = historyEntries(for: entry.domain)
+        guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
+        let nextIndex = index + 1
+        guard siblings.indices.contains(nextIndex) else { return nil }
+        return siblings[nextIndex]
+    }
+
+    func toggleSnapshotSelection(_ entry: HistoryEntry) {
+        if selectedSnapshotIDs.contains(entry.id) {
+            selectedSnapshotIDs.remove(entry.id)
+        } else if selectedSnapshotIDs.count < 2 {
+            selectedSnapshotIDs.insert(entry.id)
+        } else if let oldest = selectedSnapshotIDs.first {
+            selectedSnapshotIDs.remove(oldest)
+            selectedSnapshotIDs.insert(entry.id)
+        }
+    }
+
+    func clearSnapshotSelection() {
+        selectedSnapshotIDs.removeAll()
+    }
+
+    var selectedSnapshots: [HistoryEntry] {
+        selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
+    }
+
+    @discardableResult
+    func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
+        guard selectedSnapshots.count == 2 else {
+            activeDomainDiff = nil
+            activeDiffChangeIndex = 0
+            return nil
+        }
+
+        let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
+        activeDomainDiff = diff
+        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+            activeDiffChangeIndex = index
+        } else {
+            activeDiffChangeIndex = 0
         }
-        .sorted { $0.timestamp > $1.timestamp }
+        return diff
+    }
 
-        return siblings.first?.snapshot
+    func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
+        selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
+        let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
+        activeDomainDiff = diff
+        if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+            activeDiffChangeIndex = index
+        } else {
+            activeDiffChangeIndex = 0
+        }
+        return diff
     }
 
     func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
@@ -2662,6 +2898,23 @@ final class DomainViewModel {
         return history.first(where: { $0.id == historyEntryID })
     }
 
+    var currentDiffTargetSectionID: String? {
+        guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
+            return nil
+        }
+        return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
+    }
+
+    func moveToNextDiffChange() {
+        guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
+        activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
+    }
+
+    func moveToPreviousDiffChange() {
+        guard activeDomainDiff != nil else { return }
+        activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
+    }
+
     private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
         let latestEntries = latestSnapshots(for: domains)
 
@@ -2709,6 +2962,10 @@ final class DomainViewModel {
         }
     }
 
+    private func timelineReports(for domain: String) -> [DomainReport] {
+        historyEntries(for: domain).map { report(for: $0) }
+    }
+
     private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
         reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
     }
@@ -2745,6 +3002,10 @@ final class DomainViewModel {
             isPartialSnapshot: true,
             validationIssues: ["No stored snapshot data available"],
             totalLookupDurationMs: nil,
+            snapshotIndex: nil,
+            previousSnapshotID: nil,
+            changeCount: 0,
+            severitySummary: trackedDomain.lastChangeSeverity,
             dnsSections: [],
             dnsError: nil,
             availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
@@ -2795,6 +3056,14 @@ final class DomainViewModel {
         return DomainDataPortabilityService.loadHistoryEntries()
     }
 
+    private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
+        guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
+              let option = HistoryAutoPruneOption(rawValue: rawValue) else {
+            return .unlimited
+        }
+        return option
+    }
+
     private static func loadTrackedDomains() -> [TrackedDomain] {
         DataMigrationService.migrateIfNeeded()
         return DomainDataPortabilityService.loadTrackedDomains()
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 6eb6777..be7548a 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -7,8 +7,12 @@ struct HistoryView: View {
     @State private var showClearAllConfirmation = false
     @State private var showWorkflowAddSheet = false
 
-    private var groupedHistory: [HistoryGroup] {
-        HistoryGroup.groups(for: viewModel.filteredHistory)
+    private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] {
+        viewModel.timelineDomains.compactMap { domain in
+            let entries = viewModel.timelineEntries(for: domain)
+            guard let latest = entries.first else { return nil }
+            return (domain, latest, entries.count)
+        }
     }
 
     var body: some View {
@@ -22,55 +26,41 @@ struct HistoryView: View {
                 )
                     .listRowBackground(Color(.systemGray6).opacity(0.5))
             } else {
-                ForEach(groupedHistory) { group in
-                    Section(group.title) {
-                        ForEach(group.entries) { entry in
-                            NavigationLink {
-                                HistoryDetailView(viewModel: viewModel, entry: entry)
-                            } label: {
-                                VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
-                                    HStack(alignment: .center, spacing: 8) {
-                                        Text(entry.domain)
-                                            .font(appDensity.font(.callout))
-                                            .foregroundStyle(.primary)
-                                        Spacer()
-                                        AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary))
-                                    }
-
-                                    HStack(spacing: 8) {
-                                        AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status))
-                                        AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError))
-                                        if entry.isPartialSnapshot {
-                                            AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)))
-                                        }
-                                    }
+                Section("Domains") {
+                    ForEach(domainSummaries, id: \.domain) { item in
+                        NavigationLink {
+                            TimelineView(viewModel: viewModel, domain: item.domain)
+                        } label: {
+                            VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+                                HStack(alignment: .center, spacing: 8) {
+                                    Text(item.domain)
+                                        .font(appDensity.font(.callout))
+                                        .foregroundStyle(.primary)
+                                    Spacer()
+                                    Text("\(item.count) snapshots")
+                                        .font(appDensity.font(.caption2))
+                                        .foregroundStyle(.secondary)
+                                }
 
-                                    HStack(spacing: 8) {
-                                        Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
-                                        Text(entry.timestamp.formatted(.relative(presentation: .named)))
-                                        Text(entry.resolverDisplayName)
-                                        if let totalLookupDurationMs = entry.totalLookupDurationMs {
-                                            Text("\(totalLookupDurationMs) ms")
-                                        }
-                                    }
-                                    .font(appDensity.font(.caption2))
+                                Text(item.latest.changeSummaryMessage ?? "No change summary")
+                                    .font(appDensity.font(.caption))
                                     .foregroundStyle(.secondary)
+                                    .lineLimit(2)
 
-                                    if let note = entry.note, !note.isEmpty {
-                                        Text(note)
-                                            .font(appDensity.font(.caption2))
-                                            .foregroundStyle(.secondary)
-                                            .lineLimit(1)
+                                HStack(spacing: 8) {
+                                    AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability))
+                                    if let severity = item.latest.severitySummary {
+                                        AppStatusBadgeView(
+                                            model: .init(
+                                                title: severity.title,
+                                                systemImage: "arrow.triangle.2.circlepath",
+                                                foregroundColor: severity == .high ? .red : .yellow,
+                                                backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
+                                            )
+                                        )
                                     }
                                 }
                             }
-                            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
-                                Button(role: .destructive) {
-                                    viewModel.removeHistoryEntries(withIDs: [entry.id])
-                                } label: {
-                                    Label("Delete", systemImage: "trash")
-                                }
-                            }
                         }
                         .listRowBackground(Color(.systemGray6).opacity(0.5))
                     }
@@ -80,7 +70,7 @@ struct HistoryView: View {
         .scrollContentBackground(.hidden)
         .background(Color.black)
         .navigationTitle("History")
-        .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
+        .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains")
         .toolbar {
             if !viewModel.history.isEmpty {
                 ToolbarItemGroup(placement: .topBarTrailing) {
@@ -244,7 +234,8 @@ struct HistoryDetailView: View {
                         title: "Compared With Previous Snapshot",
                         sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
                         contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot),
-                        showsUnchanged: false
+                        showsUnchanged: false,
+                        highlightedSectionID: nil
                     )
                     .padding(.top, appDensity.metrics.sectionSpacing)
                 }
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 25d1ba2..51273b4 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -541,6 +541,83 @@ enum HistorySortOption: String, CaseIterable, Identifiable {
     }
 }
 
+enum TimelineGroupingOption: String, CaseIterable, Identifiable {
+    case none
+    case relativeDay
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .none:
+            return "Ungrouped"
+        case .relativeDay:
+            return "Today / Yesterday / Older"
+        }
+    }
+}
+
+enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable {
+    case keep50
+    case keep100
+    case unlimited
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .keep50:
+            return "Keep Last 50"
+        case .keep100:
+            return "Keep Last 100"
+        case .unlimited:
+            return "Unlimited"
+        }
+    }
+
+    var keepCount: Int? {
+        switch self {
+        case .keep50:
+            return 50
+        case .keep100:
+            return 100
+        case .unlimited:
+            return nil
+        }
+    }
+}
+
+struct SnapshotSummary: Identifiable, Codable, Equatable {
+    let id: UUID
+    let domain: String
+    let timestamp: Date
+    let trackedDomainID: UUID?
+    let snapshotIndex: Int?
+    let previousSnapshotID: UUID?
+    let changeCount: Int
+    let severitySummary: ChangeSeverity?
+    let changeSummaryMessage: String?
+    let availability: DomainAvailabilityStatus?
+    let primaryIP: String?
+    let tlsStatus: String?
+    let riskScore: Int?
+    let historyEntryID: UUID
+
+    var hasChanges: Bool {
+        changeCount > 0
+    }
+}
+
+struct FullSnapshot: Codable {
+    let historyEntry: HistoryEntry
+}
+
+struct TimelineSection: Identifiable, Equatable {
+    let id: String
+    let title: String
+    let entries: [SnapshotSummary]
+}
+
 enum WatchlistFilterOption: String, CaseIterable, Identifiable {
     case all
     case pinnedOnly
@@ -1304,6 +1381,10 @@ struct HistoryEntry: Identifiable, Codable {
     var emailSecuritySummary: String?
     var httpGradeSummary: String?
     var changeSummary: DomainChangeSummary?
+    var snapshotIndex: Int?
+    var previousSnapshotID: UUID?
+    var changeCount: Int
+    var severitySummary: ChangeSeverity?
     var sslError: String?
     var httpHeadersError: String?
     var reachabilityError: String?
@@ -1339,7 +1420,8 @@ struct HistoryEntry: Identifiable, Codable {
          validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
          totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
          tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
-         changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
+         changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
+         changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
          reachabilityError: String? = nil, ipGeolocationError: String? = nil,
          emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
          ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
@@ -1389,6 +1471,10 @@ struct HistoryEntry: Identifiable, Codable {
         self.emailSecuritySummary = emailSecuritySummary
         self.httpGradeSummary = httpGradeSummary
         self.changeSummary = changeSummary
+        self.snapshotIndex = snapshotIndex
+        self.previousSnapshotID = previousSnapshotID
+        self.changeCount = changeCount
+        self.severitySummary = severitySummary
         self.sslError = sslError
         self.httpHeadersError = httpHeadersError
         self.reachabilityError = reachabilityError
@@ -1452,6 +1538,13 @@ struct HistoryEntry: Identifiable, Codable {
         emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
         httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
         changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
+        snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
+        previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
+        changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
+            ?? changeSummary?.changedSections.count
+            ?? 0
+        severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
+            ?? changeSummary?.severity
         sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
         httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
         reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
@@ -1478,6 +1571,25 @@ struct HistoryEntry: Identifiable, Codable {
         }
         return issues
     }
+
+    var snapshotSummary: SnapshotSummary {
+        SnapshotSummary(
+            id: id,
+            domain: domain,
+            timestamp: timestamp,
+            trackedDomainID: trackedDomainID,
+            snapshotIndex: snapshotIndex,
+            previousSnapshotID: previousSnapshotID,
+            changeCount: changeCount,
+            severitySummary: severitySummary ?? changeSummary?.severity,
+            changeSummaryMessage: changeSummary?.message,
+            availability: availabilityResult?.status,
+            primaryIP: primaryIP,
+            tlsStatus: tlsStatusSummary,
+            riskScore: changeSummary?.riskAssessment?.score,
+            historyEntryID: id
+        )
+    }
 }
 
 // MARK: - Cloudflare DNS-over-HTTPS Response
diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift
index 36c2827..1f3e649 100644
--- a/DomainDig/PortScanService.swift
+++ b/DomainDig/PortScanService.swift
@@ -124,7 +124,7 @@ struct PortScanService {
     }
 
     private static func probe(domain: String, port: UInt16) async -> PortProbeResult {
-        await probe(domain: domain, port: port, timeout: 5)
+        await probe(domain: domain, port: port, timeout: 1.5)
     }
 
     private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult {
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift
index e7e2c93..6d67e45 100644
--- a/DomainDig/RDAPService.swift
+++ b/DomainDig/RDAPService.swift
@@ -48,6 +48,7 @@ enum RDAPService {
 }
 
 private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
+    let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain)
     guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
         return .error("Unavailable")
     }
@@ -55,9 +56,11 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
     do {
         var request = URLRequest(url: url, timeoutInterval: 8)
         request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
+        DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8")
 
         let (data, response) = try await URLSession.shared.data(for: request)
         guard let httpResponse = response as? HTTPURLResponse else {
+            DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil")
             return .error(URLError(.badServerResponse).localizedDescription)
         }
 
@@ -66,15 +69,22 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
             let decoder = JSONDecoder()
             let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
             guard rdapResponse.isDomainRecord else {
+                DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord")
                 return .empty("Unavailable")
             }
+            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)")
             return .success(rdapResponse)
         case 404:
+            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404")
             return .empty("Unavailable")
         default:
+            DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)")
+            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)")
             return .error("Unavailable")
         }
     } catch {
+        DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)")
+        DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error")
         return .error(error.localizedDescription)
     }
 }
diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift
index 3fb7dd9..207499f 100644
--- a/DomainDig/ReachabilityService.swift
+++ b/DomainDig/ReachabilityService.swift
@@ -23,7 +23,7 @@ struct ReachabilityService {
             let queue = DispatchQueue(label: "reachability.\(port)")
             connection.start(queue: queue)
 
-            queue.asyncAfter(deadline: .now() + 5) {
+            queue.asyncAfter(deadline: .now() + 2) {
                 context.finish(reachable: false)
             }
         }
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
index 7339648..4511edd 100644
--- a/DomainDig/SubdomainDiscoveryService.swift
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -17,6 +17,7 @@ enum SubdomainDiscoveryService {
     }
 
     private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+        let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain)
         var components = URLComponents(string: "https://crt.sh/")!
         components.queryItems = [
             URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -29,15 +30,25 @@ enum SubdomainDiscoveryService {
 
         do {
             let request = URLRequest(url: url, timeoutInterval: 10)
+            DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10")
             let (data, response) = try await URLSession.shared.data(for: request)
             guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+                DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)")
                 return .error("Subdomain discovery unavailable")
             }
 
             let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
             let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
+            DomainDebugLog.signpostEnd(
+                "SubdomainDiscovery.fetch",
+                start: startedAt,
+                domain: domain,
+                extra: "entries=\(entries.count) subdomains=\(subdomains.count)"
+            )
             return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
         } catch {
+            DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)")
+            DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error")
             return .error(error.localizedDescription)
         }
     }
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
new file mode 100644
index 0000000..404fc1e
--- /dev/null
+++ b/DomainDig/TimelineView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct TimelineView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+    let domain: String
+
+    @State private var presentedDiff: DomainDiff?
+    @State private var focusedSectionID: String?
+
+    private var timelineSections: [TimelineSection] {
+        viewModel.timelineSections(for: domain)
+    }
+
+    var body: some View {
+        List {
+            ForEach(timelineSections) { section in
+                Section(section.title) {
+                    ForEach(section.entries) { summary in
+                        if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
+                            NavigationLink {
+                                HistoryDetailView(viewModel: viewModel, entry: entry)
+                            } label: {
+                                TimelineRow(summary: summary)
+                            }
+                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                                Button {
+                                    viewModel.toggleSnapshotSelection(entry)
+                                } label: {
+                                    Label(
+                                        viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
+                                        systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
+                                    )
+                                }
+
+                                Button(role: .destructive) {
+                                    viewModel.removeHistoryEntries(withIDs: [entry.id])
+                                } label: {
+                                    Label("Delete", systemImage: "trash")
+                                }
+                            }
+                        }
+                    }
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+                }
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle(domain)
+        .toolbar {
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                Menu {
+                    Picker("Grouping", selection: $viewModel.timelineGrouping) {
+                        ForEach(TimelineGroupingOption.allCases) { option in
+                            Text(option.title).tag(option)
+                        }
+                    }
+                } label: {
+                    Image(systemName: "line.3.horizontal.decrease.circle")
+                }
+
+                Button("Compare") {
+                    presentedDiff = viewModel.generateDiffForSelectedSnapshots()
+                    focusedSectionID = viewModel.currentDiffTargetSectionID
+                }
+                .disabled(viewModel.selectedSnapshots.count != 2)
+
+                Menu("Export") {
+                    Button("Export TXT") {
+                        ExportPresenter.share(
+                            filename: "\(domain)-timeline.txt",
+                            contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
+                        )
+                    }
+
+                    Button("Export JSON") {
+                        guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
+                        ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
+                    }
+                }
+            }
+        }
+        .sheet(item: $presentedDiff) { diff in
+            NavigationStack {
+                TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
+            }
+        }
+    }
+}
+
+private struct TimelineRow: View {
+    @Environment(\.appDensity) private var appDensity
+    let summary: SnapshotSummary
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+            HStack(alignment: .center, spacing: 8) {
+                Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
+                    .font(appDensity.font(.callout))
+                    .foregroundStyle(.primary)
+                Spacer()
+                if let severity = summary.severitySummary {
+                    AppStatusBadgeView(
+                        model: .init(
+                            title: severity.title,
+                            systemImage: "arrow.triangle.2.circlepath",
+                            foregroundColor: severity == .high ? .red : .yellow,
+                            backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
+                        )
+                    )
+                }
+            }
+
+            Text(summary.changeSummaryMessage ?? "No change summary")
+                .font(appDensity.font(.caption))
+                .foregroundStyle(.secondary)
+                .lineLimit(2)
+
+            HStack(spacing: 8) {
+                AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
+                if let primaryIP = summary.primaryIP {
+                    AppStatusBadgeView(
+                        model: .init(
+                            title: primaryIP,
+                            systemImage: "network",
+                            foregroundColor: .blue,
+                            backgroundColor: .blue.opacity(0.16)
+                        )
+                    )
+                }
+                if let tlsStatus = summary.tlsStatus {
+                    AppStatusBadgeView(
+                        model: .init(
+                            title: tlsStatus.capitalized,
+                            systemImage: "lock.shield",
+                            foregroundColor: .green,
+                            backgroundColor: .green.opacity(0.16)
+                        )
+                    )
+                }
+                if let riskScore = summary.riskScore {
+                    AppStatusBadgeView(
+                        model: .init(
+                            title: "Risk \(riskScore)",
+                            systemImage: "exclamationmark.shield",
+                            foregroundColor: riskScore >= 70 ? .red : .orange,
+                            backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
+                        )
+                    )
+                }
+            }
+
+            HStack(spacing: 8) {
+                if let snapshotIndex = summary.snapshotIndex {
+                    Text("#\(snapshotIndex)")
+                }
+                Text(summary.timestamp.formatted(.relative(presentation: .named)))
+                if summary.changeCount > 0 {
+                    Text("\(summary.changeCount) changes")
+                }
+            }
+            .font(appDensity.font(.caption2))
+            .foregroundStyle(.secondary)
+        }
+    }
+}
+
+struct TimelineDiffView: View {
+    @Bindable var viewModel: DomainViewModel
+    let diff: DomainDiff
+    @Binding var focusedSectionID: String?
+
+    var body: some View {
+        ScrollViewReader { proxy in
+            ScrollView {
+                VStack(alignment: .leading, spacing: 12) {
+                    HStack {
+                        Button("Previous Change") {
+                            viewModel.moveToPreviousDiffChange()
+                            focusedSectionID = viewModel.currentDiffTargetSectionID
+                            scroll(proxy: proxy)
+                        }
+                        .disabled(viewModel.activeDiffChangeIndex == 0)
+
+                        Button("Next Change") {
+                            viewModel.moveToNextDiffChange()
+                            focusedSectionID = viewModel.currentDiffTargetSectionID
+                            scroll(proxy: proxy)
+                        }
+                        .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
+
+                        Spacer()
+                    }
+
+                    DomainDiffView(
+                        title: "Snapshot Diff",
+                        sections: diff.sections,
+                        contextNote: diff.contextNote,
+                        showsUnchanged: false,
+                        highlightedSectionID: focusedSectionID
+                    )
+                }
+                .padding()
+            }
+            .background(Color.black)
+            .navigationTitle("Compare Snapshots")
+            .navigationBarTitleDisplayMode(.inline)
+            .onAppear {
+                scroll(proxy: proxy)
+            }
+            .onChange(of: focusedSectionID) { _, _ in
+                scroll(proxy: proxy)
+            }
+        }
+    }
+
+    private func scroll(proxy: ScrollViewProxy) {
+        guard let focusedSectionID else { return }
+        withAnimation {
+            proxy.scrollTo(focusedSectionID, anchor: .top)
+        }
+    }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 1a38282..31c6202 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -483,7 +483,8 @@ struct TrackedDomainDetailView: View {
                         contextNote: latestSnapshots.count >= 2
                             ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
                             : nil,
-                        showsUnchanged: false
+                        showsUnchanged: false,
+                        highlightedSectionID: nil
                     )
                 }
                 .listRowBackground(Color.clear)
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index bb9dd80..da289a9 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -17,6 +17,16 @@ struct DomainDigCLI {
             return
         }
 
+        if arguments.first == "history" {
+            runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
+            return
+        }
+
+        if arguments.first == "diff" {
+            runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
+            return
+        }
+
         if arguments.first == "monitor" {
             await runMonitorCommand(wantsJSON: wantsJSON)
             return
@@ -216,6 +226,10 @@ struct DomainDigCLI {
             isPartialSnapshot: snapshot.isPartialSnapshot,
             validationIssues: snapshot.validationIssues,
             totalLookupDurationMs: snapshot.totalLookupDurationMs,
+            snapshotIndex: snapshot.snapshotIndex,
+            previousSnapshotID: snapshot.previousSnapshotID,
+            changeCount: snapshot.changeCount,
+            severitySummary: snapshot.severitySummary,
             dnsSections: snapshot.dnsSections,
             dnsError: snapshot.dnsError,
             availabilityResult: snapshot.availabilityResult,
@@ -265,6 +279,107 @@ struct DomainDigCLI {
         DomainDataPortabilityService.loadHistoryEntries()
     }
 
+    private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) {
+        guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
+            fputs("usage: domaindig history <domain> [--json]\n", stderr)
+            Foundation.exit(1)
+        }
+
+        let entries = loadHistoryEntries()
+            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+            .sorted { $0.timestamp > $1.timestamp }
+
+        if wantsJSON {
+            let encoder = JSONEncoder()
+            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+            encoder.dateEncodingStrategy = .iso8601
+            let payload = entries.map { entry in
+                [
+                    "id": entry.id.uuidString,
+                    "timestamp": ISO8601DateFormatter().string(from: entry.timestamp),
+                    "changeSummary": entry.changeSummary?.message ?? "No change summary",
+                    "changeCount": "\(entry.changeCount)",
+                    "severity": entry.severitySummary?.title ?? "N/A"
+                ]
+            }
+            if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) {
+                FileHandle.standardOutput.write(data)
+                FileHandle.standardOutput.write(Data([0x0A]))
+                return
+            }
+        }
+
+        let lines = entries.map { entry in
+            [
+                entry.id.uuidString,
+                entry.timestamp.formatted(date: .abbreviated, time: .shortened),
+                entry.changeSummary?.message ?? "No change summary"
+            ].joined(separator: " | ")
+        }
+
+        FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8))
+    }
+
+    private static func runDiffCommand(arguments: [String], wantsJSON: Bool) {
+        guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
+            fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr)
+            Foundation.exit(1)
+        }
+
+        guard let fromID = optionValue(named: "--from", in: arguments),
+              let toID = optionValue(named: "--to", in: arguments),
+              let fromUUID = UUID(uuidString: fromID),
+              let toUUID = UUID(uuidString: toID) else {
+            fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr)
+            Foundation.exit(1)
+        }
+
+        let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+        guard let fromEntry = entries.first(where: { $0.id == fromUUID }),
+              let toEntry = entries.first(where: { $0.id == toUUID }) else {
+            fputs("domaindig diff: snapshots not found for domain\n", stderr)
+            Foundation.exit(1)
+        }
+
+        let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp }
+        let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot)
+
+        if wantsJSON {
+            let encoder = JSONEncoder()
+            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+            encoder.dateEncodingStrategy = .iso8601
+            if let data = try? encoder.encode(diff) {
+                FileHandle.standardOutput.write(data)
+                FileHandle.standardOutput.write(Data([0x0A]))
+                return
+            }
+        }
+
+        var lines = [
+            "DomainDig Diff",
+            "Domain: \(domain)",
+            "From: \(orderedEntries[0].id.uuidString)",
+            "To: \(orderedEntries[1].id.uuidString)"
+        ]
+
+        for section in diff.sections where section.hasChanges {
+            lines.append("")
+            lines.append(section.title)
+            for item in section.items where item.hasChanges {
+                lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")")
+            }
+        }
+
+        FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8))
+    }
+
+    private static func optionValue(named name: String, in arguments: [String]) -> String? {
+        guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else {
+            return nil
+        }
+        return arguments[index + 1]
+    }
+
     private static func runBackupCommand(arguments: [String], wantsJSON: Bool) {
         guard let subcommand = arguments.first else {
             fputs(usageText, stderr)
@@ -424,6 +539,8 @@ struct DomainDigCLI {
     private static var usageText: String {
         """
         usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]
+               domaindig history <domain> [--json]
+               domaindig diff <domain> --from <id> --to <id> [--json]
                domaindig monitor [--json]
                domaindig backup export [path]
                domaindig backup import <path> [--replace]
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index a4a1172..55e87f5 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -15,6 +15,7 @@ struct DomainInspectionService {
 
     func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
         let normalizedDomain = normalize(domain)
+        let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain)
         let startedAt = Date()
         let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
         let resolverURLString = DNSLookupService.currentResolverURLString()
@@ -29,13 +30,12 @@ struct DomainInspectionService {
         async let sslFetch = runtime.ssl(domain: normalizedDomain)
         async let hstsFetch = runtime.hsts(domain: normalizedDomain)
         async let httpFetch = runtime.http(domain: normalizedDomain)
-        async let reachabilityFetch = runtime.reachability(domain: normalizedDomain)
         async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
         async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
         async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
-        async let portScanFetch = runtime.portScan(domain: normalizedDomain)
 
         let resolvedDNS = await dnsFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)")
         let dnsResult = normalizeErrors(in: resolvedDNS.value)
         track(
             .dns,
@@ -49,6 +49,7 @@ struct DomainInspectionService {
         captureFailure(for: .dns, result: dnsResult, into: &errorDetails)
 
         let availability = await availabilityFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)")
         track(
             .availability,
             source: availability.source,
@@ -60,6 +61,7 @@ struct DomainInspectionService {
         )
 
         let resolvedSSL = await sslFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)")
         let sslResult = normalizeErrors(in: resolvedSSL.value)
         track(
             .ssl,
@@ -73,6 +75,7 @@ struct DomainInspectionService {
         captureFailure(for: .ssl, result: sslResult, into: &errorDetails)
 
         let hsts = await hstsFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)")
         track(
             .hsts,
             source: hsts.source,
@@ -84,6 +87,7 @@ struct DomainInspectionService {
         )
 
         let http = await httpFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)")
         let httpResult = normalizeErrors(in: http.value)
         track(
             .httpHeaders,
@@ -96,20 +100,8 @@ struct DomainInspectionService {
         )
         captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails)
 
-        let reachability = await reachabilityFetch
-        let reachabilityResult = normalizeErrors(in: reachability.value)
-        track(
-            .reachability,
-            source: reachability.source,
-            provenance: provenance(for: .reachability, source: reachability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
-            cachedSections: &cachedSections,
-            sectionSources: &sectionSources,
-            provenanceBySection: &provenanceBySection,
-            dataSources: &dataSources
-        )
-        captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
-
         let resolvedOwnership = await ownershipFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)")
         let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
         track(
             .ownership,
@@ -123,6 +115,7 @@ struct DomainInspectionService {
         captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails)
 
         let redirects = await redirectFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)")
         let redirectResult = normalizeErrors(in: redirects.value)
         track(
             .redirectChain,
@@ -136,6 +129,7 @@ struct DomainInspectionService {
         captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails)
 
         let resolvedSubdomains = await subdomainFetch
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)")
         let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
         track(
             .subdomains,
@@ -148,33 +142,68 @@ struct DomainInspectionService {
         )
         captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails)
 
-        let ports = await portScanFetch
-        let portScanResult = normalizeErrors(in: ports.value)
-        track(
-            .portScan,
-            source: ports.source,
-            provenance: provenance(for: .portScan, source: ports.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
-            cachedSections: &cachedSections,
-            sectionSources: &sectionSources,
-            provenanceBySection: &provenanceBySection,
-            dataSources: &dataSources
-        )
-        captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
-
         let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
         let sslInfo = mapOptionalValueServiceResult(sslResult)
         let httpHeadersResult = mapHTTPResult(httpResult)
-        let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
         let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
         let ownership = mapOptionalValueServiceResult(ownershipResult)
         let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
-        let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain)
 
         let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
         let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
+        let hasNetworkTarget = dnsSections.value.contains { section in
+            (section.recordType == .A || section.recordType == .AAAA)
+                && (!section.records.isEmpty || !section.wildcardRecords.isEmpty)
+        }
         let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
         let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
 
+        let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>>
+        if hasNetworkTarget {
+            reachabilityOutcome = await runtime.reachability(domain: normalizedDomain)
+        } else {
+            reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
+        }
+        let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value)
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)")
+        track(
+            .reachability,
+            source: reachabilityOutcome.source,
+            provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
+            cachedSections: &cachedSections,
+            sectionSources: &sectionSources,
+            provenanceBySection: &provenanceBySection,
+            dataSources: &dataSources
+        )
+        if hasNetworkTarget {
+            captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
+        }
+        let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
+
+        let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>>
+        if hasNetworkTarget {
+            portScanOutcome = await runtime.portScan(domain: normalizedDomain)
+        } else {
+            portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
+        }
+        let portScanResult = normalizeErrors(in: portScanOutcome.value)
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)")
+        track(
+            .portScan,
+            source: portScanOutcome.source,
+            provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
+            cachedSections: &cachedSections,
+            sectionSources: &sectionSources,
+            provenanceBySection: &provenanceBySection,
+            dataSources: &dataSources
+        )
+        if hasNetworkTarget {
+            captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
+        }
+        let portScanResults = hasNetworkTarget
+            ? await mapPortScanResult(portScanResult, domain: normalizedDomain)
+            : (value: [], message: nil)
+
         let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
         if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
             emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
@@ -184,6 +213,7 @@ struct DomainInspectionService {
             emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
         }
         let normalizedEmailResult = normalizeErrors(in: emailOutcome.value)
+        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)")
         track(
             .emailSecurity,
             source: emailOutcome.source,
@@ -271,7 +301,7 @@ struct DomainInspectionService {
         let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message)
         let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection)
 
-        return LookupSnapshot(
+        let snapshot = LookupSnapshot(
             historyEntryID: nil,
             domain: availability.value.domain,
             timestamp: Date(),
@@ -291,6 +321,10 @@ struct DomainInspectionService {
             isPartialSnapshot: !validationIssues.isEmpty,
             validationIssues: validationIssues,
             totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
+            snapshotIndex: nil,
+            previousSnapshotID: previousSnapshot?.historyEntryID,
+            changeCount: 0,
+            severitySummary: nil,
             dnsSections: dnsSections.value,
             dnsError: dnsSections.message,
             availabilityResult: availability.value,
@@ -334,6 +368,13 @@ struct DomainInspectionService {
             cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
             statusMessage: nil
         )
+        DomainDebugLog.signpostEnd(
+            "Inspection.inspectSnapshot",
+            start: inspectionStartedAt,
+            domain: normalizedDomain,
+            extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)"
+        )
+        return snapshot
     }
 
     private func normalize(_ domain: String) -> String {
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index 3ed88ac..e48b673 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -62,6 +62,10 @@ struct DomainReportMetadata: Codable {
     let isPartialSnapshot: Bool
     let errorDetails: [LookupSectionKind: InspectionFailure]
     let statusMessage: String?
+    let snapshotIndex: Int?
+    let previousSnapshotID: UUID?
+    let changeCount: Int
+    let severitySummary: ChangeSeverity?
 }
 
 struct DNSResultSummary: Codable {
@@ -123,26 +127,103 @@ struct DomainReportBuilder {
     func build(
         from snapshot: LookupSnapshot,
         previousSnapshot: LookupSnapshot? = nil,
-        workflowContext: DomainWorkflowContext? = nil
+        workflowContext: DomainWorkflowContext? = nil,
+        deriveChangeSummary: Bool = true
     ) -> DomainReport {
+        let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
         let primaryIP = primaryIPAddress(from: snapshot)
         let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
         let changeSummary: DomainChangeSummary?
         if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil {
             changeSummary = existingChangeSummary
-        } else {
-            changeSummary = previousSnapshot.map {
-                DomainDiffService.summary(
-                    from: $0,
-                    to: snapshot,
-                    generatedAt: snapshot.timestamp,
-                    riskAssessment: analysis.riskAssessment,
-                    insights: analysis.insights
-                )
+        } else if deriveChangeSummary, let previousSnapshot {
+            let previousReport = build(
+                from: previousSnapshot,
+                workflowContext: workflowContext,
+                deriveChangeSummary: false
+            )
+            let currentReport = buildBaseReport(
+                from: snapshot,
+                previousSnapshot: previousSnapshot,
+                workflowContext: workflowContext,
+                analysis: analysis,
+                primaryIP: primaryIP,
+                changeSummary: nil as DomainChangeSummary?
+            )
+            let diff = DiffService.compare(from: previousReport, to: currentReport)
+            let changedItems = diff.sections.flatMap { $0.items }.filter { $0.hasChanges }
+            let highlights = diff.changedSectionTitles
+            let severity = changedItems.map { $0.severity }.max() ?? .low
+            let message = DiffService.summaryMessage(from: highlights, changeCount: changedItems.count)
+            let observedFacts = changedItems.prefix(4).map { item in
+                "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
             }
+            let previousRiskScore = previousReport.riskAssessment.score
+            let riskScoreDelta = analysis.riskAssessment.score - previousRiskScore
+            let impactClassification = DomainInsightEngine.impactClassification(
+                severity: severity,
+                riskDelta: riskScoreDelta,
+                changedSections: highlights
+            )
+
+            changeSummary = DomainChangeSummary(
+                hasChanges: !changedItems.isEmpty,
+                changedSections: highlights,
+                message: message,
+                severity: severity,
+                impactClassification: impactClassification,
+                generatedAt: snapshot.timestamp,
+                observedFacts: observedFacts,
+                inferredConclusions: highlights.isEmpty ? [] : [message],
+                contextNote: diff.contextNote,
+                riskAssessment: analysis.riskAssessment,
+                insights: analysis.insights,
+                riskScoreDelta: riskScoreDelta
+            )
+        } else {
+            changeSummary = nil
         }
 
-        return DomainReport(
+        let report = buildBaseReport(
+            from: snapshot,
+            previousSnapshot: previousSnapshot,
+            workflowContext: workflowContext,
+            analysis: analysis,
+            primaryIP: primaryIP,
+            changeSummary: changeSummary
+        )
+        DomainDebugLog.signpostEnd(
+            "DomainReportBuilder.build",
+            start: buildStartedAt,
+            domain: snapshot.domain,
+            extra: "risk=\(report.riskAssessment.score) insights=\(report.insights.count)"
+        )
+        return report
+    }
+
+    func build(
+        from entry: HistoryEntry,
+        previousSnapshot: LookupSnapshot? = nil,
+        workflowContext: DomainWorkflowContext? = nil,
+        deriveChangeSummary: Bool = true
+    ) -> DomainReport {
+        build(
+            from: entry.snapshot,
+            previousSnapshot: previousSnapshot,
+            workflowContext: workflowContext,
+            deriveChangeSummary: deriveChangeSummary
+        )
+    }
+
+    private func buildBaseReport(
+        from snapshot: LookupSnapshot,
+        previousSnapshot: LookupSnapshot?,
+        workflowContext: DomainWorkflowContext?,
+        analysis: DomainAnalysisBundle,
+        primaryIP: String?,
+        changeSummary: DomainChangeSummary?
+    ) -> DomainReport {
+        DomainReport(
             domain: snapshot.domain,
             timestamp: snapshot.timestamp,
             provenance: DomainReportProvenance(
@@ -230,7 +311,7 @@ struct DomainReportBuilder {
             changeSummary: changeSummary,
             workflowContext: workflowContext,
             metadata: DomainReportMetadata(
-                schemaVersion: "3.2.0",
+                schemaVersion: "3.7.0",
                 resolverDisplayName: snapshot.resolverDisplayName,
                 resolverURLString: snapshot.resolverURLString,
                 appVersion: snapshot.appVersion,
@@ -239,19 +320,15 @@ struct DomainReportBuilder {
                 validationIssues: snapshot.validationIssues,
                 isPartialSnapshot: snapshot.isPartialSnapshot,
                 errorDetails: snapshot.errorDetails,
-                statusMessage: snapshot.statusMessage
+                statusMessage: snapshot.statusMessage,
+                snapshotIndex: snapshot.snapshotIndex,
+                previousSnapshotID: snapshot.previousSnapshotID ?? previousSnapshot?.historyEntryID,
+                changeCount: snapshot.changeCount == 0 ? (changeSummary?.changedSections.count ?? 0) : snapshot.changeCount,
+                severitySummary: snapshot.severitySummary ?? changeSummary?.severity
             )
         )
     }
 
-    func build(
-        from entry: HistoryEntry,
-        previousSnapshot: LookupSnapshot? = nil,
-        workflowContext: DomainWorkflowContext? = nil
-    ) -> DomainReport {
-        build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext)
-    }
-
     private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
         snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
     }
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 4fa7470..55b7372 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -504,6 +504,62 @@ enum DomainReportExporter {
             .joined(separator: "\n")
     }
 
+    static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
+        guard !reports.isEmpty else {
+            return "Timeline Export\nNo snapshots available for \(domain)."
+        }
+
+        var lines = [
+            "DomainDig Timeline Export",
+            "Domain: \(domain)",
+            "Snapshots: \(reports.count)"
+        ]
+
+        for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
+            lines.append("")
+            lines.append("\(textDateFormatter.string(from: report.timestamp))")
+            lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
+            lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
+            if includeDiffSummary, let changeSummary = report.changeSummary {
+                lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
+            }
+        }
+
+        return lines.joined(separator: "\n")
+    }
+
+    static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
+        struct TimelineExportEntry: Codable {
+            let timestamp: Date
+            let summary: String?
+            let severity: String?
+            let changedSections: [String]?
+        }
+
+        struct TimelineExportPayload: Codable {
+            let domain: String
+            let exportedAt: Date
+            let snapshots: [TimelineExportEntry]
+        }
+
+        let payload = TimelineExportPayload(
+            domain: domain,
+            exportedAt: Date(),
+            snapshots: reports
+                .sorted(by: { $0.timestamp > $1.timestamp })
+                .map { report in
+                    TimelineExportEntry(
+                        timestamp: report.timestamp,
+                        summary: report.changeSummary?.message,
+                        severity: report.changeSummary?.severity.title,
+                        changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
+                    )
+                }
+        )
+
+        return try jsonEncoder.encode(payload)
+    }
+
     private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
         lines.append("")
         lines.append(title)
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index a817805..752e7f2 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -20,6 +20,10 @@ struct LookupSnapshot {
     let isPartialSnapshot: Bool
     let validationIssues: [String]
     let totalLookupDurationMs: Int?
+    let snapshotIndex: Int?
+    let previousSnapshotID: UUID?
+    let changeCount: Int
+    let severitySummary: ChangeSeverity?
     let dnsSections: [DNSSection]
     let dnsError: String?
     let availabilityResult: DomainAvailabilityResult?
@@ -90,6 +94,10 @@ extension HistoryEntry {
             isPartialSnapshot: isPartialSnapshot,
             validationIssues: validationIssues,
             totalLookupDurationMs: totalLookupDurationMs,
+            snapshotIndex: snapshotIndex,
+            previousSnapshotID: previousSnapshotID,
+            changeCount: changeCount,
+            severitySummary: severitySummary,
             dnsSections: dnsSections,
             dnsError: nil,
             availabilityResult: availabilityResult,