krz/domain-dig

an ios app for DNS & SSL analysis

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

237a72a03102319638c5b0572ca3ab543238b821

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-26T05:39:28Z

DomainDig v3.5.0: Expand the Pro+ Data+ intelligence layer with deeper local historical context
and inferred enrichment.

- add derived intelligence fields for provider fingerprinting, classification,
  ownership transitions, hosting transitions, subdomain history, risk signals,
  and inferred timeline events
- expand DNS history beyond A/NS snapshots to retain A, AAAA, MX, NS, TXT, and
  CNAME change state
- persist enriched intelligence in snapshots and history entries so analysis is
  local-first and incremental
- add a dedicated Data+ Intelligence panel to current and historical domain
  detail views
- surface intelligence events in timeline rows and include Data+ changes in diff
  output
- preserve non-blocking inspection behavior by keeping enrichment additive to
  the main lookup path

This makes Pro+ materially deeper for investigative workflows by improving
historical ownership visibility, infrastructure context, hosting change
detection, subdomain intelligence, and explainable risk signals.
 DomainDig.xcodeproj/project.pbxproj     |   8 +-
 DomainDig/ContentView.swift             | 133 ++++++++++
 DomainDig/DiffService.swift             |  15 ++
 DomainDig/DomainMonitoringService.swift |   8 +
 DomainDig/DomainViewModel.swift         |  56 +++-
 DomainDig/ExternalDataService.swift     | 116 +++++---
 DomainDig/HistoryView.swift             |  12 +-
 DomainDig/Models.swift                  | 251 ++++++++++++++++++
 DomainDig/TimelineView.swift            |  14 +-
 DomainDigCLI.swift                      |   8 +
 DomainInspectionService.swift           |   8 +
 DomainReportBuilder.swift               | 457 +++++++++++++++++++++++++++++++-
 LookupSnapshot.swift                    |  16 ++
 13 files changed, 1061 insertions(+), 41 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 02634ff..29813b0 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -378,7 +378,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 34;
+				CURRENT_PROJECT_VERSION = 35;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -395,7 +395,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.2.0;
+				MARKETING_VERSION = 4.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -415,7 +415,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 34;
+				CURRENT_PROJECT_VERSION = 35;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -432,7 +432,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.2.0;
+				MARKETING_VERSION = 4.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 3716f88..5a469e1 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -11,6 +11,7 @@ enum LookupInputMode: String, CaseIterable, Identifiable {
 
 enum ResultSection: String, Hashable {
     case domain
+    case intelligence
     case ownership
     case dns
     case web
@@ -78,6 +79,10 @@ struct ContentView: View {
                                     .padding(.top, appDensity.metrics.cardSpacing)
                             }
                         }
+                        if let report = viewModel.currentReport {
+                            intelligenceSection(report: report)
+                                .padding(.top, appDensity.metrics.sectionSpacing)
+                        }
                         domainOverviewSection
                             .padding(.top, appDensity.metrics.sectionSpacing)
                         ownershipSection
@@ -415,6 +420,14 @@ struct ContentView: View {
         )
     }
 
+    private func intelligenceSection(report: DomainReport) -> some View {
+        IntelligenceSectionView(
+            isCollapsed: sectionCollapsedBinding(.intelligence),
+            report: report,
+            showsPlaceholder: FeatureAccessService.currentTier != .proPlus
+        )
+    }
+
     private var ownershipSection: some View {
         OwnershipSectionView(
             isCollapsed: sectionCollapsedBinding(.ownership),
@@ -1558,6 +1571,126 @@ struct OwnershipSectionView: View {
     }
 }
 
+struct IntelligenceSectionView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Binding var isCollapsed: Bool
+    let report: DomainReport
+    let showsPlaceholder: Bool
+
+    var body: some View {
+        CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
+            CardView(allowsHorizontalScroll: false) {
+                if showsPlaceholder {
+                    MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
+                } else {
+                    if let provider = report.inferredProvider {
+                        intelligenceBlock(title: "Infrastructure") {
+                            LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
+                            if !provider.evidence.isEmpty {
+                                MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false)
+                            }
+                            if !report.priorProviders.isEmpty {
+                                LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
+                            }
+                        }
+                    }
+                    if let classification = report.domainClassification {
+                        intelligenceBlock(title: "Classification") {
+                            LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
+                            MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false)
+                        }
+                    }
+                    intelligenceBlock(title: "Risk Signals") {
+                        if report.riskSignals.isEmpty {
+                            MessageRowView(text: "No material historical risk signals detected", isError: false)
+                        } else {
+                            ForEach(report.riskSignals.prefix(4)) { signal in
+                                VStack(alignment: .leading, spacing: 3) {
+                                    Text(signal.title)
+                                        .font(appDensity.font(.caption, weight: .semibold))
+                                    Text(signal.detail)
+                                        .font(appDensity.font(.caption2))
+                                        .foregroundStyle(.secondary)
+                                }
+                            }
+                        }
+                    }
+                    intelligenceBlock(title: "Ownership History") {
+                        if report.ownershipTransitions.isEmpty {
+                            MessageRowView(text: "No ownership transitions observed locally", isError: false)
+                        } else {
+                            ForEach(report.ownershipTransitions.prefix(4)) { event in
+                                intelligenceEventRow(date: event.date, title: event.summary)
+                            }
+                        }
+                    }
+                    intelligenceBlock(title: "Hosting History") {
+                        if report.hostingTransitions.isEmpty {
+                            MessageRowView(text: "No hosting transitions observed locally", isError: false)
+                        } else {
+                            ForEach(report.hostingTransitions.prefix(4)) { event in
+                                intelligenceEventRow(date: event.date, title: event.summary)
+                            }
+                        }
+                    }
+                    intelligenceBlock(title: "Subdomain Intelligence") {
+                        if report.subdomainHistory.isEmpty {
+                            MessageRowView(text: "No subdomain history available", isError: false)
+                        } else {
+                            ForEach(report.subdomainHistory.prefix(5)) { item in
+                                VStack(alignment: .leading, spacing: 3) {
+                                    HStack {
+                                        Text(item.hostname)
+                                            .font(appDensity.font(.caption))
+                                        Spacer()
+                                        if item.isEphemeral {
+                                            Text("Ephemeral")
+                                                .font(appDensity.font(.caption2))
+                                                .foregroundStyle(.yellow)
+                                        }
+                                    }
+                                    Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
+                                        .font(appDensity.font(.caption2))
+                                        .foregroundStyle(.secondary)
+                                }
+                            }
+                        }
+                    }
+                    intelligenceBlock(title: "Timeline") {
+                        if report.intelligenceTimeline.isEmpty {
+                            MessageRowView(text: "No inferred intelligence events yet", isError: false)
+                        } else {
+                            ForEach(report.intelligenceTimeline.prefix(5)) { event in
+                                intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text(title)
+                .font(appDensity.font(.subheadline, weight: .semibold))
+                .foregroundStyle(.cyan)
+            content()
+        }
+    }
+
+    private func intelligenceEventRow(date: Date, title: String) -> some View {
+        VStack(alignment: .leading, spacing: 3) {
+            Text(date.formatted(date: .abbreviated, time: .omitted))
+                .font(appDensity.font(.caption2))
+                .foregroundStyle(.secondary)
+            Text(title)
+                .font(appDensity.font(.caption))
+        }
+    }
+}
+
 struct SubdomainsSectionView: View {
     @Environment(\.appDensity) private var appDensity
     @Binding var isCollapsed: Bool
diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift
index 948f470..211a16e 100644
--- a/DomainDig/DiffService.swift
+++ b/DomainDig/DiffService.swift
@@ -115,6 +115,7 @@ enum DiffService {
             emailSection(from: oldReport, to: newReport),
             networkSection(from: oldReport, to: newReport),
             subdomainsSection(from: oldReport, to: newReport),
+            intelligenceSection(from: oldReport, to: newReport),
             riskSection(from: oldReport, to: newReport)
         ]
 
@@ -384,6 +385,20 @@ enum DiffService {
         )
     }
 
+    private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+        DiffSection(
+            id: "intelligence",
+            title: "Data+ Intelligence",
+            items: [
+                compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium),
+                compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium),
+                compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium),
+                compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high),
+                compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium)
+            ].compactMap { $0 }
+        )
+    }
+
     private static func compare(
         id: String,
         label: String,
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 31b1505..3229fb7 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -910,6 +910,14 @@ final class DomainMonitoringService {
             ownershipError: previousSnapshot.ownershipError,
             ownershipHistory: previousSnapshot.ownershipHistory,
             ownershipHistoryError: previousSnapshot.ownershipHistoryError,
+            inferredProvider: previousSnapshot.inferredProvider,
+            priorProviders: previousSnapshot.priorProviders,
+            domainClassification: previousSnapshot.domainClassification,
+            ownershipTransitions: previousSnapshot.ownershipTransitions,
+            hostingTransitions: previousSnapshot.hostingTransitions,
+            subdomainHistory: previousSnapshot.subdomainHistory,
+            riskSignals: previousSnapshot.riskSignals,
+            intelligenceTimeline: previousSnapshot.intelligenceTimeline,
             ptrRecord: previousSnapshot.ptrRecord,
             ptrError: previousSnapshot.ptrError,
             redirectChain: previousSnapshot.redirectChain,
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 1d75221..88e2ec6 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -621,6 +621,14 @@ final class DomainViewModel {
             ownershipError: ownershipError,
             ownershipHistory: ownershipHistory,
             ownershipHistoryError: ownershipHistoryError,
+            inferredProvider: currentHistoryEntry?.inferredProvider ?? currentReport?.inferredProvider,
+            priorProviders: currentHistoryEntry?.priorProviders ?? currentReport?.priorProviders ?? [],
+            domainClassification: currentHistoryEntry?.domainClassification ?? currentReport?.domainClassification,
+            ownershipTransitions: currentHistoryEntry?.ownershipTransitions ?? currentReport?.ownershipTransitions ?? [],
+            hostingTransitions: currentHistoryEntry?.hostingTransitions ?? currentReport?.hostingTransitions ?? [],
+            subdomainHistory: currentHistoryEntry?.subdomainHistory ?? currentReport?.subdomainHistory ?? [],
+            riskSignals: currentHistoryEntry?.riskSignals ?? currentReport?.riskSignals ?? [],
+            intelligenceTimeline: currentHistoryEntry?.intelligenceTimeline ?? currentReport?.intelligenceTimeline ?? [],
             ptrRecord: ptrRecord,
             ptrError: ptrError,
             redirectChain: redirectChain,
@@ -1750,7 +1758,8 @@ final class DomainViewModel {
                 for: snapshot.domain,
                 trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id,
                 replacingLatest: false
-            )
+            ),
+            historyEntries: historyEntries(for: snapshot.domain)
         )
         DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
         currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
@@ -1873,6 +1882,14 @@ final class DomainViewModel {
             ownershipError: previousSnapshot.ownershipError,
             ownershipHistory: previousSnapshot.ownershipHistory,
             ownershipHistoryError: previousSnapshot.ownershipHistoryError,
+            inferredProvider: previousSnapshot.inferredProvider,
+            priorProviders: previousSnapshot.priorProviders,
+            domainClassification: previousSnapshot.domainClassification,
+            ownershipTransitions: previousSnapshot.ownershipTransitions,
+            hostingTransitions: previousSnapshot.hostingTransitions,
+            subdomainHistory: previousSnapshot.subdomainHistory,
+            riskSignals: previousSnapshot.riskSignals,
+            intelligenceTimeline: previousSnapshot.intelligenceTimeline,
             ptrRecord: previousSnapshot.ptrRecord,
             ptrError: previousSnapshot.ptrError,
             redirectChain: previousSnapshot.redirectChain,
@@ -2228,7 +2245,15 @@ final class DomainViewModel {
     ) -> HistoryEntry? {
         let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
         let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
+        let domainHistoryEntries = history.filter {
+            $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame
+        }
         let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
+        let intelligence = DomainIntelligenceService.derive(
+            snapshot: snapshot,
+            previousSnapshot: previousSnapshot,
+            historyEntries: domainHistoryEntries
+        )
         let changeSummary = reuseCurrentAnalysis
             ? currentChangeSummary ?? snapshot.changeSummary
             : previousSnapshot.map {
@@ -2262,7 +2287,11 @@ final class DomainViewModel {
             currentDiffSections = diffSections
             ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
             if !reuseCurrentAnalysis {
-                currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
+                currentReport = reportBuilder.build(
+                    from: snapshot,
+                    previousSnapshot: previousSnapshot,
+                    historyEntries: domainHistoryEntries
+                )
             }
         }
 
@@ -2280,6 +2309,14 @@ final class DomainViewModel {
             mtaSts: snapshot.emailSecurity?.mtaSts,
             ownership: snapshot.ownership,
             ownershipHistory: snapshot.ownershipHistory,
+            inferredProvider: intelligence.inferredProvider,
+            priorProviders: intelligence.priorProviders,
+            domainClassification: intelligence.domainClassification,
+            ownershipTransitions: intelligence.ownershipTransitions,
+            hostingTransitions: intelligence.hostingTransitions,
+            subdomainHistory: intelligence.subdomainHistory,
+            riskSignals: intelligence.riskSignals,
+            intelligenceTimeline: intelligence.timelineEvents,
             ptrRecord: snapshot.ptrRecord,
             redirectChain: snapshot.redirectChain,
             subdomains: snapshot.subdomains,
@@ -3509,7 +3546,12 @@ final class DomainViewModel {
     }
 
     private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
-        reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
+        reportBuilder.build(
+            from: entry,
+            previousSnapshot: comparisonSnapshot(for: entry),
+            workflowContext: workflowContext,
+            historyEntries: historyEntries(for: entry.domain)
+        )
     }
 
     private var activeWorkflowContext: DomainWorkflowContext? {
@@ -3572,6 +3614,14 @@ final class DomainViewModel {
             ownershipError: nil,
             ownershipHistory: [],
             ownershipHistoryError: nil,
+            inferredProvider: nil,
+            priorProviders: [],
+            domainClassification: nil,
+            ownershipTransitions: [],
+            hostingTransitions: [],
+            subdomainHistory: [],
+            riskSignals: [],
+            intelligenceTimeline: [],
             ptrRecord: nil,
             ptrError: nil,
             redirectChain: [],
diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift
index 5b4bf66..65f117e 100644
--- a/DomainDig/ExternalDataService.swift
+++ b/DomainDig/ExternalDataService.swift
@@ -364,6 +364,8 @@ actor ExternalDataService {
                 summary: event["summary"] as? String ?? "DNS change observed",
                 aRecords: event["a_records"] as? [String] ?? [],
                 nameservers: event["nameservers"] as? [String] ?? [],
+                recordSnapshots: parseDNSRecordSnapshots(from: event),
+                changedRecordTypes: parseDNSChangedRecordTypes(from: event),
                 source: event["source"] as? String ?? "Configured external history feed",
                 isExternal: true
             )
@@ -459,46 +461,44 @@ actor ExternalDataService {
             .sorted { $0.timestamp < $1.timestamp }
 
         var events: [DNSHistoryEvent] = []
-        var previousARecords: [String] = []
-        var previousNameservers: [String] = []
+        var previousRecordValues: [DNSRecordType: [String]] = [:]
 
         for entry in domainHistory {
-            let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections)
-            let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections)
-            let summary = dnsSummaryChange(
-                previousARecords: previousARecords,
-                currentARecords: aRecords,
-                previousNameservers: previousNameservers,
-                currentNameservers: nameservers
-            )
+            let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections)
+            let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues)
+            let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues)
 
             if let summary {
                 events.append(
                     DNSHistoryEvent(
                         date: entry.timestamp,
                         summary: summary,
-                        aRecords: aRecords,
-                        nameservers: nameservers,
+                        aRecords: currentRecordValues[.A] ?? [],
+                        nameservers: currentRecordValues[.NS] ?? [],
+                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
+                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
+                        changedRecordTypes: changedRecordTypes,
                         source: "Local observations",
                         isExternal: false
                     )
                 )
             }
 
-            previousARecords = aRecords
-            previousNameservers = nameservers
+            previousRecordValues = currentRecordValues
         }
 
         if events.isEmpty {
-            let currentARecords = Self.dnsValues(for: .A, in: dnsSections)
-            let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections)
-            if !currentARecords.isEmpty || !currentNameservers.isEmpty {
+            let currentRecordValues = Self.historyRecordValues(in: dnsSections)
+            if !currentRecordValues.isEmpty {
                 events.append(
                     DNSHistoryEvent(
                         date: Date(),
                         summary: "Current DNS snapshot",
-                        aRecords: currentARecords,
-                        nameservers: currentNameservers,
+                        aRecords: currentRecordValues[.A] ?? [],
+                        nameservers: currentRecordValues[.NS] ?? [],
+                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
+                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
+                        changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue },
                         source: "Local observations",
                         isExternal: false
                     )
@@ -540,8 +540,7 @@ actor ExternalDataService {
                 let duplicate = partialResult.contains {
                     $0.date == event.date
                         && $0.summary == event.summary
-                        && $0.aRecords == event.aRecords
-                        && $0.nameservers == event.nameservers
+                        && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots)
                 }
                 if !duplicate {
                     partialResult.append(event)
@@ -585,19 +584,18 @@ actor ExternalDataService {
     }
 
     private static func dnsSummaryChange(
-        previousARecords: [String],
-        currentARecords: [String],
-        previousNameservers: [String],
-        currentNameservers: [String]
+        previous: [DNSRecordType: [String]],
+        current: [DNSRecordType: [String]]
     ) -> String? {
         var changes: [String] = []
-        if previousARecords != currentARecords, !currentARecords.isEmpty {
-            changes.append("A records changed")
-        }
-        if previousNameservers != currentNameservers, !currentNameservers.isEmpty {
-            changes.append("NS records changed")
+        for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] {
+            let previousValues = previous[type] ?? []
+            let currentValues = current[type] ?? []
+            if previousValues != currentValues, !currentValues.isEmpty {
+                changes.append("\(type.rawValue) records changed")
+            }
         }
-        if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) {
+        if previous.isEmpty && !current.isEmpty {
             changes.append("Initial DNS observation")
         }
         return changes.isEmpty ? nil : changes.joined(separator: " • ")
@@ -619,6 +617,62 @@ actor ExternalDataService {
             .sorted() ?? []
     }
 
+    private func parseDNSRecordSnapshots(from event: [String: Any]) -> [DNSHistoryRecordSnapshot] {
+        if let snapshots = event["record_snapshots"] as? [[String: Any]] {
+            return snapshots.compactMap { item in
+                guard let typeName = item["type"] as? String,
+                      let type = DNSRecordType(rawValue: typeName) else {
+                    return nil
+                }
+                return DNSHistoryRecordSnapshot(recordType: type, values: item["values"] as? [String] ?? [])
+            }
+        }
+        var snapshots: [DNSHistoryRecordSnapshot] = []
+        if let aRecords = event["a_records"] as? [String], !aRecords.isEmpty {
+            snapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
+        }
+        if let nameservers = event["nameservers"] as? [String], !nameservers.isEmpty {
+            snapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
+        }
+        return snapshots
+    }
+
+    private func parseDNSChangedRecordTypes(from event: [String: Any]) -> [DNSRecordType] {
+        if let rawTypes = event["changed_record_types"] as? [String] {
+            return rawTypes.compactMap(DNSRecordType.init(rawValue:))
+        }
+        return parseDNSRecordSnapshots(from: event).map(\.recordType)
+    }
+
+    private static func historyRecordValues(in sections: [DNSSection]) -> [DNSRecordType: [String]] {
+        let trackedTypes: [DNSRecordType] = [.A, .AAAA, .MX, .NS, .TXT, .CNAME]
+        return trackedTypes.reduce(into: [DNSRecordType: [String]]()) { result, type in
+            let values = dnsValues(for: type, in: sections)
+            if !values.isEmpty {
+                result[type] = values
+            }
+        }
+    }
+
+    private static func changedRecordTypes(
+        previous: [DNSRecordType: [String]],
+        current: [DNSRecordType: [String]]
+    ) -> [DNSRecordType] {
+        Array(Set(previous.keys).union(current.keys))
+            .filter { previous[$0] != current[$0] }
+            .sorted { $0.rawValue < $1.rawValue }
+    }
+
+    private static func compareDNSRecordSnapshots(
+        _ lhs: [DNSHistoryRecordSnapshot],
+        _ rhs: [DNSHistoryRecordSnapshot]
+    ) -> Bool {
+        guard lhs.count == rhs.count else { return false }
+        return zip(lhs, rhs).allSatisfy { left, right in
+            left.recordType == right.recordType && left.values == right.values
+        }
+    }
+
     private static let iso8601DateFormatter: ISO8601DateFormatter = {
         let formatter = ISO8601DateFormatter()
         formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 3d6151c..316fcce 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -159,7 +159,11 @@ struct HistoryDetailView: View {
     }
 
     private var report: DomainReport {
-        DomainReportBuilder().build(from: entry, previousSnapshot: viewModel.comparisonSnapshot(for: entry))
+        DomainReportBuilder().build(
+            from: entry,
+            previousSnapshot: viewModel.comparisonSnapshot(for: entry),
+            historyEntries: viewModel.historyEntries(for: entry.domain)
+        )
     }
 
     private var trackedDomain: TrackedDomain? {
@@ -176,6 +180,12 @@ struct HistoryDetailView: View {
                     .padding(.top, 8)
                 InsightsSummaryCardView(insights: report.insights)
                     .padding(.top, 8)
+                IntelligenceSectionView(
+                    isCollapsed: .constant(false),
+                    report: report,
+                    showsPlaceholder: FeatureAccessService.currentTier != .proPlus
+                )
+                .padding(.top, 8)
                 DomainSectionView(
                     isCollapsed: .constant(false),
                     rows: DomainViewModel.domainRows(from: snapshot),
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 8e1d494..376d264 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
     let summary: String
     let aRecords: [String]
     let nameservers: [String]
+    let recordSnapshots: [DNSHistoryRecordSnapshot]
+    let changedRecordTypes: [DNSRecordType]
     let source: String
     let isExternal: Bool
 
@@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
         summary: String,
         aRecords: [String] = [],
         nameservers: [String] = [],
+        recordSnapshots: [DNSHistoryRecordSnapshot] = [],
+        changedRecordTypes: [DNSRecordType] = [],
         source: String,
         isExternal: Bool
     ) {
@@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
         self.summary = summary
         self.aRecords = aRecords
         self.nameservers = nameservers
+        self.recordSnapshots = recordSnapshots
+        self.changedRecordTypes = changedRecordTypes
         self.source = source
         self.isExternal = isExternal
     }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        date = try container.decode(Date.self, forKey: .date)
+        summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed"
+        aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? []
+        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
+        let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? []
+        if decodedRecordSnapshots.isEmpty {
+            var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = []
+            if !aRecords.isEmpty {
+                synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
+            }
+            if !nameservers.isEmpty {
+                synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
+            }
+            recordSnapshots = synthesizedSnapshots
+        } else {
+            recordSnapshots = decodedRecordSnapshots
+        }
+        changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes)
+            ?? recordSnapshots.map(\.recordType)
+        source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown"
+        isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
+    }
+}
+
+struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable {
+    let id: UUID
+    let recordType: DNSRecordType
+    let values: [String]
+
+    nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) {
+        self.id = id
+        self.recordType = recordType
+        self.values = values
+    }
+
+    static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool {
+        lhs.recordType == rhs.recordType && lhs.values == rhs.values
+    }
+}
+
+struct InferredProviderFingerprint: Codable, Equatable, Sendable {
+    let name: String
+    let confidence: ConfidenceLevel
+    let evidence: [String]
+}
+
+enum DomainClassificationKind: String, Codable, CaseIterable, Sendable {
+    case marketing
+    case app
+    case api
+    case auth
+    case docs
+    case staticSite = "static"
+    case infrastructure
+    case status
+    case unknown
+
+    var title: String {
+        switch self {
+        case .staticSite:
+            return "Static"
+        default:
+            return rawValue.capitalized
+        }
+    }
+}
+
+struct DomainClassificationSummary: Codable, Equatable, Sendable {
+    let kind: DomainClassificationKind
+    let confidence: ConfidenceLevel
+    let reasons: [String]
+}
+
+struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable {
+    let id: UUID
+    let date: Date
+    let summary: String
+    let previousRegistrar: String?
+    let currentRegistrar: String?
+    let previousRegistrant: String?
+    let currentRegistrant: String?
+    let previousNameservers: [String]
+    let currentNameservers: [String]
+
+    nonisolated init(
+        id: UUID = UUID(),
+        date: Date,
+        summary: String,
+        previousRegistrar: String? = nil,
+        currentRegistrar: String? = nil,
+        previousRegistrant: String? = nil,
+        currentRegistrant: String? = nil,
+        previousNameservers: [String] = [],
+        currentNameservers: [String] = []
+    ) {
+        self.id = id
+        self.date = date
+        self.summary = summary
+        self.previousRegistrar = previousRegistrar
+        self.currentRegistrar = currentRegistrar
+        self.previousRegistrant = previousRegistrant
+        self.currentRegistrant = currentRegistrant
+        self.previousNameservers = previousNameservers
+        self.currentNameservers = currentNameservers
+    }
+}
+
+struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable {
+    let id: UUID
+    let date: Date
+    let fromProvider: String
+    let toProvider: String
+    let summary: String
+
+    nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) {
+        self.id = id
+        self.date = date
+        self.fromProvider = fromProvider
+        self.toProvider = toProvider
+        self.summary = summary
+    }
+}
+
+struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable {
+    let id: String
+    let hostname: String
+    let firstSeen: Date
+    let lastSeen: Date
+    let recurrenceCount: Int
+    let statusChangeCount: Int
+    let lastKnownStatus: String
+    let isEphemeral: Bool
+
+    nonisolated init(
+        hostname: String,
+        firstSeen: Date,
+        lastSeen: Date,
+        recurrenceCount: Int,
+        statusChangeCount: Int,
+        lastKnownStatus: String,
+        isEphemeral: Bool
+    ) {
+        id = hostname.lowercased()
+        self.hostname = hostname
+        self.firstSeen = firstSeen
+        self.lastSeen = lastSeen
+        self.recurrenceCount = recurrenceCount
+        self.statusChangeCount = statusChangeCount
+        self.lastKnownStatus = lastKnownStatus
+        self.isEphemeral = isEphemeral
+    }
+}
+
+struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable {
+    let id: String
+    let title: String
+    let detail: String
+    let severity: ChangeSeverity
+    let firstObserved: Date?
+    let lastObserved: Date?
+
+    nonisolated init(
+        id: String,
+        title: String,
+        detail: String,
+        severity: ChangeSeverity,
+        firstObserved: Date? = nil,
+        lastObserved: Date? = nil
+    ) {
+        self.id = id
+        self.title = title
+        self.detail = detail
+        self.severity = severity
+        self.firstObserved = firstObserved
+        self.lastObserved = lastObserved
+    }
+}
+
+enum IntelligenceTimelineEventCategory: String, Codable, Sendable {
+    case ownership
+    case dns
+    case hosting
+    case subdomain
+    case classification
+    case risk
+}
+
+struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable {
+    let id: UUID
+    let date: Date
+    let category: IntelligenceTimelineEventCategory
+    let title: String
+    let detail: String
+    let severity: ChangeSeverity
+
+    nonisolated init(
+        id: UUID = UUID(),
+        date: Date,
+        category: IntelligenceTimelineEventCategory,
+        title: String,
+        detail: String,
+        severity: ChangeSeverity
+    ) {
+        self.id = id
+        self.date = date
+        self.category = category
+        self.title = title
+        self.detail = detail
+        self.severity = severity
+    }
 }
 
 struct DomainPricingInsight: Codable, Equatable, Sendable {
@@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable {
     var mtaSts: MTASTSResult?
     var ownership: DomainOwnership?
     var ownershipHistory: [DomainOwnershipHistoryEvent]
+    var inferredProvider: InferredProviderFingerprint?
+    var priorProviders: [String]
+    var domainClassification: DomainClassificationSummary?
+    var ownershipTransitions: [OwnershipTransitionEvent]
+    var hostingTransitions: [HostingTransitionEvent]
+    var subdomainHistory: [SubdomainHistoryEntry]
+    var riskSignals: [IntelligenceRiskSignal]
+    var intelligenceTimeline: [IntelligenceTimelineEvent]
     var ptrRecord: String?
     var redirectChain: [RedirectHop]
     var subdomains: [DiscoveredSubdomain]
@@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable {
          reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
          emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
          ownershipHistory: [DomainOwnershipHistoryEvent] = [],
+         inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
+         domainClassification: DomainClassificationSummary? = nil,
+         ownershipTransitions: [OwnershipTransitionEvent] = [],
+         hostingTransitions: [HostingTransitionEvent] = [],
+         subdomainHistory: [SubdomainHistoryEntry] = [],
+         riskSignals: [IntelligenceRiskSignal] = [],
+         intelligenceTimeline: [IntelligenceTimelineEvent] = [],
          ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
          extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
          domainPricing: DomainPricingInsight? = nil,
@@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable {
         self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
         self.ownership = ownership
         self.ownershipHistory = ownershipHistory
+        self.inferredProvider = inferredProvider
+        self.priorProviders = priorProviders
+        self.domainClassification = domainClassification
+        self.ownershipTransitions = ownershipTransitions
+        self.hostingTransitions = hostingTransitions
+        self.subdomainHistory = subdomainHistory
+        self.riskSignals = riskSignals
+        self.intelligenceTimeline = intelligenceTimeline
         self.ptrRecord = ptrRecord
         self.redirectChain = redirectChain
         self.subdomains = subdomains
@@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable {
         mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
         ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
         ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
+        inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
+        priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
+        domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
+        ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
+        hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
+        subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
+        riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
+        intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
         ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
         redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
         subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
index b163c76..8680d15 100644
--- a/DomainDig/TimelineView.swift
+++ b/DomainDig/TimelineView.swift
@@ -25,7 +25,7 @@ struct TimelineView: View {
                             NavigationLink {
                                 HistoryDetailView(viewModel: viewModel, entry: entry)
                             } label: {
-                                TimelineRow(summary: summary)
+                                TimelineRow(summary: summary, entry: entry)
                             }
                             .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                                 Button {
@@ -102,6 +102,7 @@ struct TimelineView: View {
 private struct TimelineRow: View {
     @Environment(\.appDensity) private var appDensity
     let summary: SnapshotSummary
+    let entry: HistoryEntry
 
     var body: some View {
         VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
@@ -138,6 +139,17 @@ private struct TimelineRow: View {
             .font(appDensity.font(.caption2))
             .foregroundStyle(.secondary)
 
+            if !entry.intelligenceTimeline.isEmpty {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(Array(entry.intelligenceTimeline.prefix(2))) { event in
+                        Text("\(event.title): \(event.detail)")
+                            .font(appDensity.font(.caption2))
+                            .foregroundStyle(.secondary)
+                            .lineLimit(1)
+                    }
+                }
+            }
+
             HStack(spacing: 8) {
                 if let primaryIP = summary.primaryIP {
                     Text(primaryIP)
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index 00944ee..00713fd 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -219,6 +219,14 @@ struct DomainDigCLI {
             ownershipError: snapshot.ownershipError,
             ownershipHistory: ownershipHistory,
             ownershipHistoryError: ownershipHistoryError,
+            inferredProvider: snapshot.inferredProvider,
+            priorProviders: snapshot.priorProviders,
+            domainClassification: snapshot.domainClassification,
+            ownershipTransitions: snapshot.ownershipTransitions,
+            hostingTransitions: snapshot.hostingTransitions,
+            subdomainHistory: snapshot.subdomainHistory,
+            riskSignals: snapshot.riskSignals,
+            intelligenceTimeline: snapshot.intelligenceTimeline,
             ptrRecord: snapshot.ptrRecord,
             ptrError: snapshot.ptrError,
             redirectChain: snapshot.redirectChain,
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index 55e87f5..0562195 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -349,6 +349,14 @@ struct DomainInspectionService {
             ownershipError: ownership.message,
             ownershipHistory: [],
             ownershipHistoryError: nil,
+            inferredProvider: nil,
+            priorProviders: [],
+            domainClassification: nil,
+            ownershipTransitions: [],
+            hostingTransitions: [],
+            subdomainHistory: [],
+            riskSignals: [],
+            intelligenceTimeline: [],
             ptrRecord: ptrRecord.value,
             ptrError: ptrRecord.message,
             redirectChain: redirectChain.value,
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index afed9db..50c46db 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -22,6 +22,14 @@ struct DomainReport: Codable {
     let geolocationConfidence: ConfidenceLevel?
     let ownership: DomainOwnership?
     let ownershipHistory: [DomainOwnershipHistoryEvent]
+    let inferredProvider: InferredProviderFingerprint?
+    let priorProviders: [String]
+    let domainClassification: DomainClassificationSummary?
+    let ownershipTransitions: [OwnershipTransitionEvent]
+    let hostingTransitions: [HostingTransitionEvent]
+    let subdomainHistory: [SubdomainHistoryEntry]
+    let riskSignals: [IntelligenceRiskSignal]
+    let intelligenceTimeline: [IntelligenceTimelineEvent]
     let dns: DNSResultSummary
     let web: WebResultSummary
     let email: EmailSecuritySummary
@@ -133,6 +141,7 @@ struct DomainReportBuilder {
         from snapshot: LookupSnapshot,
         previousSnapshot: LookupSnapshot? = nil,
         workflowContext: DomainWorkflowContext? = nil,
+        historyEntries: [HistoryEntry] = [],
         deriveChangeSummary: Bool = true
     ) -> DomainReport {
         let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
@@ -145,12 +154,14 @@ struct DomainReportBuilder {
             let previousReport = build(
                 from: previousSnapshot,
                 workflowContext: workflowContext,
+                historyEntries: historyEntries,
                 deriveChangeSummary: false
             )
             let currentReport = buildBaseReport(
                 from: snapshot,
                 previousSnapshot: previousSnapshot,
                 workflowContext: workflowContext,
+                historyEntries: historyEntries,
                 analysis: analysis,
                 primaryIP: primaryIP,
                 changeSummary: nil as DomainChangeSummary?
@@ -193,6 +204,7 @@ struct DomainReportBuilder {
             from: snapshot,
             previousSnapshot: previousSnapshot,
             workflowContext: workflowContext,
+            historyEntries: historyEntries,
             analysis: analysis,
             primaryIP: primaryIP,
             changeSummary: changeSummary
@@ -210,12 +222,14 @@ struct DomainReportBuilder {
         from entry: HistoryEntry,
         previousSnapshot: LookupSnapshot? = nil,
         workflowContext: DomainWorkflowContext? = nil,
+        historyEntries: [HistoryEntry] = [],
         deriveChangeSummary: Bool = true
     ) -> DomainReport {
         build(
             from: entry.snapshot,
             previousSnapshot: previousSnapshot,
             workflowContext: workflowContext,
+            historyEntries: historyEntries,
             deriveChangeSummary: deriveChangeSummary
         )
     }
@@ -224,10 +238,16 @@ struct DomainReportBuilder {
         from snapshot: LookupSnapshot,
         previousSnapshot: LookupSnapshot?,
         workflowContext: DomainWorkflowContext?,
+        historyEntries: [HistoryEntry],
         analysis: DomainAnalysisBundle,
         primaryIP: String?,
         changeSummary: DomainChangeSummary?
     ) -> DomainReport {
+        let intelligence = DomainIntelligenceService.derive(
+            snapshot: snapshot,
+            previousSnapshot: previousSnapshot,
+            historyEntries: historyEntries
+        )
         let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
         let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
         let instabilityScore = DomainHealth.instabilityScore(
@@ -277,6 +297,14 @@ struct DomainReportBuilder {
             geolocationConfidence: snapshot.geolocationConfidence,
             ownership: snapshot.ownership,
             ownershipHistory: snapshot.ownershipHistory,
+            inferredProvider: intelligence.inferredProvider,
+            priorProviders: intelligence.priorProviders,
+            domainClassification: intelligence.domainClassification,
+            ownershipTransitions: intelligence.ownershipTransitions,
+            hostingTransitions: intelligence.hostingTransitions,
+            subdomainHistory: intelligence.subdomainHistory,
+            riskSignals: intelligence.riskSignals,
+            intelligenceTimeline: intelligence.timelineEvents,
             dns: DNSResultSummary(
                 resolverDisplayName: snapshot.resolverDisplayName,
                 resolverURLString: snapshot.resolverURLString,
@@ -343,7 +371,7 @@ struct DomainReportBuilder {
             certificateExpiryState: certificateExpiryState,
             workflowContext: workflowContext,
             metadata: DomainReportMetadata(
-                schemaVersion: "3.7.0",
+                schemaVersion: "4.3.0",
                 resolverDisplayName: snapshot.resolverDisplayName,
                 resolverURLString: snapshot.resolverURLString,
                 appVersion: snapshot.appVersion,
@@ -421,3 +449,430 @@ struct DomainReportBuilder {
         return geolocation.ip
     }
 }
+
+struct DerivedDomainIntelligence {
+    let inferredProvider: InferredProviderFingerprint?
+    let priorProviders: [String]
+    let domainClassification: DomainClassificationSummary?
+    let ownershipTransitions: [OwnershipTransitionEvent]
+    let hostingTransitions: [HostingTransitionEvent]
+    let subdomainHistory: [SubdomainHistoryEntry]
+    let riskSignals: [IntelligenceRiskSignal]
+    let timelineEvents: [IntelligenceTimelineEvent]
+}
+
+enum DomainIntelligenceService {
+    static func derive(
+        snapshot: LookupSnapshot,
+        previousSnapshot: LookupSnapshot? = nil,
+        historyEntries: [HistoryEntry]
+    ) -> DerivedDomainIntelligence {
+        let orderedHistory = historyEntries
+            .filter { $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame }
+            .sorted { $0.timestamp < $1.timestamp }
+        let observationSnapshots = mergeObservations(historyEntries: orderedHistory, currentSnapshot: snapshot)
+        let providerObservations = observationSnapshots.compactMap { observation -> (Date, InferredProviderFingerprint)? in
+            inferProvider(from: observation).map { (observation.timestamp, $0) }
+        }
+        let currentProvider = inferProvider(from: snapshot)
+        let priorProviders = Array(Set(providerObservations.dropLast().map { $0.1.name })).sorted()
+        let ownershipTransitions = ownershipTransitions(from: observationSnapshots)
+        let hostingTransitions = hostingTransitions(from: providerObservations)
+        let currentClassification = classify(snapshot: snapshot)
+        let subdomainHistory = buildSubdomainHistory(from: observationSnapshots)
+        let riskSignals = buildRiskSignals(
+            snapshot: snapshot,
+            previousSnapshot: previousSnapshot,
+            ownershipTransitions: ownershipTransitions,
+            hostingTransitions: hostingTransitions,
+            subdomainHistory: subdomainHistory
+        )
+        let timelineEvents = buildTimelineEvents(
+            snapshot: snapshot,
+            observations: observationSnapshots,
+            providerObservations: providerObservations,
+            ownershipTransitions: ownershipTransitions,
+            hostingTransitions: hostingTransitions,
+            riskSignals: riskSignals
+        )
+        return DerivedDomainIntelligence(
+            inferredProvider: currentProvider,
+            priorProviders: priorProviders,
+            domainClassification: currentClassification,
+            ownershipTransitions: ownershipTransitions,
+            hostingTransitions: hostingTransitions,
+            subdomainHistory: subdomainHistory,
+            riskSignals: riskSignals,
+            timelineEvents: timelineEvents
+        )
+    }
+
+    private static func mergeObservations(historyEntries: [HistoryEntry], currentSnapshot: LookupSnapshot) -> [LookupSnapshot] {
+        var snapshots = historyEntries.map(\.snapshot)
+        let alreadyIncluded = snapshots.contains {
+            $0.timestamp == currentSnapshot.timestamp && $0.domain.caseInsensitiveCompare(currentSnapshot.domain) == .orderedSame
+        }
+        if !alreadyIncluded {
+            snapshots.append(currentSnapshot)
+        }
+        return snapshots.sorted { $0.timestamp < $1.timestamp }
+    }
+
+    private static func inferProvider(from snapshot: LookupSnapshot) -> InferredProviderFingerprint? {
+        let headerMap = Dictionary(uniqueKeysWithValues: snapshot.httpHeaders.map { ($0.name.lowercased(), $0.value.lowercased()) })
+        let dnsProviders = dnsValues(for: .NS, in: snapshot.dnsSections) + dnsValues(for: .CNAME, in: snapshot.dnsSections)
+        let issuer = snapshot.sslInfo?.issuer.lowercased() ?? ""
+        let org = snapshot.ipGeolocation?.org?.lowercased() ?? ""
+
+        if headerMap["cf-ray"] != nil || containsAny(in: dnsProviders, matching: ["cloudflare"]) || issuer.contains("cloudflare") {
+            return provider("Cloudflare", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["cf-ray", "cloudflare"]))
+        }
+        if headerMap["x-vercel-id"] != nil || containsAny(in: dnsProviders, matching: ["vercel"]) {
+            return provider("Vercel", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-vercel-id", "vercel"]))
+        }
+        if containsHeaderValue(headerMap, value: "netlify") || containsAny(in: dnsProviders, matching: ["netlify"]) {
+            return provider("Netlify", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["netlify"]))
+        }
+        if containsHeaderValue(headerMap, value: "fastly") || headerMap["x-served-by"]?.contains("cache") == true {
+            return provider("Fastly", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["fastly", "x-served-by"]))
+        }
+        if headerMap["x-amz-cf-id"] != nil || containsHeaderValue(headerMap, value: "cloudfront") || containsAny(in: dnsProviders, matching: ["cloudfront.net"]) {
+            return provider("CloudFront", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-amz-cf-id", "cloudfront"]))
+        }
+        if containsAny(in: dnsProviders, matching: ["awsdns", "amazonaws.com"]) || org.contains("amazon") {
+            return provider("AWS", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["awsdns", "amazon"]))
+        }
+        if containsAny(in: dnsProviders, matching: ["github.io"]) || containsHeaderValue(headerMap, value: "github") {
+            return provider("GitHub Pages", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["github"]))
+        }
+        return nil
+    }
+
+    private static func classify(snapshot: LookupSnapshot) -> DomainClassificationSummary? {
+        let host = snapshot.domain.lowercased()
+        let headerValues = snapshot.httpHeaders.map { "\($0.name.lowercased()):\($0.value.lowercased())" }
+        let finalURL = snapshot.redirectChain.last?.url.lowercased() ?? ""
+
+        if host.hasPrefix("api.") || host.contains(".api.") {
+            return .init(kind: .api, confidence: .high, reasons: ["Hostname pattern"])
+        }
+        if containsAny(in: [host, finalURL], matching: ["auth", "login", "sso", "oauth"]) {
+            return .init(kind: .auth, confidence: .high, reasons: ["Auth-oriented host or redirect"])
+        }
+        if containsAny(in: [host, finalURL], matching: ["docs", "developer", "developers", "help"]) {
+            return .init(kind: .docs, confidence: .high, reasons: ["Docs-oriented host or redirect"])
+        }
+        if containsAny(in: [host, finalURL], matching: ["status", "statuspage", "health"]) {
+            return .init(kind: .status, confidence: .medium, reasons: ["Status-oriented host or redirect"])
+        }
+        if containsAny(in: [host], matching: ["cdn.", "static.", "assets.", "img."]) {
+            return .init(kind: .staticSite, confidence: .medium, reasons: ["Static asset hostname"])
+        }
+        if containsAny(in: [host], matching: ["app.", "portal.", "admin.", "dashboard."]) {
+            return .init(kind: .app, confidence: .medium, reasons: ["Application hostname"])
+        }
+        if containsAny(in: [host], matching: ["vpn.", "internal.", "infra."]) || headerValues.contains(where: { $0.contains("x-envoy") }) {
+            return .init(kind: .infrastructure, confidence: .medium, reasons: ["Infrastructure-oriented hostname or headers"])
+        }
+        if host == apexDomain(for: host) || host.hasPrefix("www.") {
+            return .init(kind: .marketing, confidence: .low, reasons: ["Apex or www host"])
+        }
+        return nil
+    }
+
+    private static func ownershipTransitions(from snapshots: [LookupSnapshot]) -> [OwnershipTransitionEvent] {
+        zip(snapshots, snapshots.dropFirst()).compactMap { previous, current in
+            guard let previousOwnership = previous.ownership, let currentOwnership = current.ownership else {
+                return nil
+            }
+            var changeParts: [String] = []
+            if previousOwnership.registrar != currentOwnership.registrar {
+                changeParts.append("registrar")
+            }
+            if previousOwnership.registrant != currentOwnership.registrant {
+                changeParts.append("ownership")
+            }
+            if normalized(previousOwnership.nameservers) != normalized(currentOwnership.nameservers) {
+                changeParts.append("nameservers")
+            }
+            guard !changeParts.isEmpty else { return nil }
+            return OwnershipTransitionEvent(
+                date: current.timestamp,
+                summary: "Changed \(changeParts.joined(separator: ", "))",
+                previousRegistrar: previousOwnership.registrar,
+                currentRegistrar: currentOwnership.registrar,
+                previousRegistrant: previousOwnership.registrant,
+                currentRegistrant: currentOwnership.registrant,
+                previousNameservers: previousOwnership.nameservers,
+                currentNameservers: currentOwnership.nameservers
+            )
+        }
+        .sorted { $0.date > $1.date }
+    }
+
+    private static func hostingTransitions(from observations: [(Date, InferredProviderFingerprint)]) -> [HostingTransitionEvent] {
+        zip(observations, observations.dropFirst()).compactMap { previous, current in
+            guard previous.1.name != current.1.name else { return nil }
+            return HostingTransitionEvent(
+                date: current.0,
+                fromProvider: previous.1.name,
+                toProvider: current.1.name,
+                summary: "Hosting moved from \(previous.1.name) to \(current.1.name)"
+            )
+        }
+        .sorted { $0.date > $1.date }
+    }
+
+    private static func buildSubdomainHistory(from snapshots: [LookupSnapshot]) -> [SubdomainHistoryEntry] {
+        struct WorkingState {
+            var firstSeen: Date
+            var lastSeen: Date
+            var recurrenceCount: Int
+            var statusChangeCount: Int
+            var lastSeenInPreviousSnapshot: Bool
+        }
+
+        var states: [String: WorkingState] = [:]
+        for snapshot in snapshots {
+            let currentHosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
+            let knownHosts = Set(states.keys).union(currentHosts)
+            for host in knownHosts {
+                let isPresent = currentHosts.contains(host)
+                if var state = states[host] {
+                    if isPresent {
+                        state.lastSeen = snapshot.timestamp
+                        state.recurrenceCount += 1
+                    }
+                    if state.lastSeenInPreviousSnapshot != isPresent {
+                        state.statusChangeCount += 1
+                    }
+                    state.lastSeenInPreviousSnapshot = isPresent
+                    states[host] = state
+                } else if isPresent {
+                    states[host] = WorkingState(
+                        firstSeen: snapshot.timestamp,
+                        lastSeen: snapshot.timestamp,
+                        recurrenceCount: 1,
+                        statusChangeCount: 0,
+                        lastSeenInPreviousSnapshot: true
+                    )
+                }
+            }
+        }
+
+        return states.map { host, state in
+            let isEphemeral = state.recurrenceCount <= 2 || state.statusChangeCount >= 2
+            return SubdomainHistoryEntry(
+                hostname: host,
+                firstSeen: state.firstSeen,
+                lastSeen: state.lastSeen,
+                recurrenceCount: state.recurrenceCount,
+                statusChangeCount: state.statusChangeCount,
+                lastKnownStatus: state.lastSeenInPreviousSnapshot ? "Active" : "Inactive",
+                isEphemeral: isEphemeral
+            )
+        }
+        .sorted { lhs, rhs in
+            if lhs.isEphemeral != rhs.isEphemeral {
+                return lhs.isEphemeral && !rhs.isEphemeral
+            }
+            return lhs.hostname < rhs.hostname
+        }
+    }
+
+    private static func buildRiskSignals(
+        snapshot: LookupSnapshot,
+        previousSnapshot: LookupSnapshot?,
+        ownershipTransitions: [OwnershipTransitionEvent],
+        hostingTransitions: [HostingTransitionEvent],
+        subdomainHistory: [SubdomainHistoryEntry]
+    ) -> [IntelligenceRiskSignal] {
+        var signals: [IntelligenceRiskSignal] = []
+        let dnsInstabilityCount = snapshot.dnsHistory.filter { !$0.changedRecordTypes.isEmpty }.count
+        let ephemeralSubdomains = subdomainHistory.filter(\.isEphemeral)
+
+        if ownershipTransitions.count >= 2 {
+            signals.append(.init(
+                id: "ownership-churn",
+                title: "Ownership churn",
+                detail: "Observed \(ownershipTransitions.count) ownership transitions in local history.",
+                severity: .high,
+                firstObserved: ownershipTransitions.last?.date,
+                lastObserved: ownershipTransitions.first?.date
+            ))
+        }
+        if dnsInstabilityCount >= 3 {
+            signals.append(.init(
+                id: "unstable-dns",
+                title: "Unstable DNS",
+                detail: "DNS history shows \(dnsInstabilityCount) recorded change events.",
+                severity: .medium,
+                firstObserved: snapshot.dnsHistory.last?.date,
+                lastObserved: snapshot.dnsHistory.first?.date
+            ))
+        }
+        if hostingTransitions.count >= 2 {
+            signals.append(.init(
+                id: "repeated-hosting-moves",
+                title: "Repeated hosting moves",
+                detail: "Infrastructure provider changed \(hostingTransitions.count) times across observations.",
+                severity: .medium,
+                firstObserved: hostingTransitions.last?.date,
+                lastObserved: hostingTransitions.first?.date
+            ))
+        }
+        if !ephemeralSubdomains.isEmpty {
+            signals.append(.init(
+                id: "ephemeral-subdomains",
+                title: "Ephemeral subdomains",
+                detail: "\(ephemeralSubdomains.count) subdomains appear short-lived or unstable.",
+                severity: ephemeralSubdomains.count >= 3 ? .medium : .low,
+                firstObserved: ephemeralSubdomains.map(\.firstSeen).min(),
+                lastObserved: ephemeralSubdomains.map(\.lastSeen).max()
+            ))
+        }
+        if let createdDate = snapshot.ownership?.createdDate {
+            let ageDays = Calendar.current.dateComponents([.day], from: createdDate, to: snapshot.timestamp).day ?? 0
+            if ageDays <= 180 {
+                signals.append(.init(
+                    id: "young-registration",
+                    title: "Short registration age",
+                    detail: "Domain registration is \(ageDays) days old.",
+                    severity: ageDays <= 90 ? .high : .medium,
+                    firstObserved: createdDate,
+                    lastObserved: snapshot.timestamp
+                ))
+            }
+        }
+        if let previousSnapshot,
+           let previousProvider = inferProvider(from: previousSnapshot)?.name,
+           let currentProvider = inferProvider(from: snapshot)?.name,
+           previousProvider != currentProvider {
+            signals.append(.init(
+                id: "recent-hosting-move",
+                title: "Recent hosting move",
+                detail: "Latest snapshot moved from \(previousProvider) to \(currentProvider).",
+                severity: .medium,
+                firstObserved: snapshot.timestamp,
+                lastObserved: snapshot.timestamp
+            ))
+        }
+        return signals.sorted { ($0.lastObserved ?? .distantPast) > ($1.lastObserved ?? .distantPast) }
+    }
+
+    private static func buildTimelineEvents(
+        snapshot: LookupSnapshot,
+        observations: [LookupSnapshot],
+        providerObservations: [(Date, InferredProviderFingerprint)],
+        ownershipTransitions: [OwnershipTransitionEvent],
+        hostingTransitions: [HostingTransitionEvent],
+        riskSignals: [IntelligenceRiskSignal]
+    ) -> [IntelligenceTimelineEvent] {
+        var events: [IntelligenceTimelineEvent] = []
+
+        events += ownershipTransitions.map {
+            .init(date: $0.date, category: .ownership, title: "Ownership transition", detail: $0.summary, severity: .high)
+        }
+        events += snapshot.dnsHistory.map {
+            .init(date: $0.date, category: .dns, title: "DNS change", detail: $0.summary, severity: $0.changedRecordTypes.contains(.A) || $0.changedRecordTypes.contains(.NS) ? .high : .medium)
+        }
+        events += hostingTransitions.map {
+            .init(date: $0.date, category: .hosting, title: "Hosting transition", detail: $0.summary, severity: .medium)
+        }
+        events += buildClassificationEvents(from: observations)
+        events += buildSubdomainDiscoveryEvents(from: observations)
+        events += riskSignals.compactMap {
+            guard let date = $0.lastObserved ?? $0.firstObserved else { return nil }
+            return IntelligenceTimelineEvent(date: date, category: .risk, title: $0.title, detail: $0.detail, severity: $0.severity)
+        }
+        if let latestProvider = providerObservations.last?.1 {
+            events.append(.init(
+                date: snapshot.timestamp,
+                category: .hosting,
+                title: "Current infrastructure",
+                detail: "Likely running on \(latestProvider.name)",
+                severity: .low
+            ))
+        }
+        return events.sorted { $0.date > $1.date }
+    }
+
+    private static func buildClassificationEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
+        let classifications = observations.compactMap { snapshot -> (Date, DomainClassificationSummary)? in
+            classify(snapshot: snapshot).map { (snapshot.timestamp, $0) }
+        }
+        return zip(classifications, classifications.dropFirst()).compactMap { previous, current in
+            guard previous.1.kind != current.1.kind else { return nil }
+            return .init(
+                date: current.0,
+                category: .classification,
+                title: "Classification changed",
+                detail: "\(previous.1.kind.title) -> \(current.1.kind.title)",
+                severity: .medium
+            )
+        }
+    }
+
+    private static func buildSubdomainDiscoveryEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
+        var seen = Set<String>()
+        var events: [IntelligenceTimelineEvent] = []
+        for snapshot in observations {
+            let hosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
+            for host in hosts where seen.insert(host).inserted {
+                events.append(.init(
+                    date: snapshot.timestamp,
+                    category: .subdomain,
+                    title: "Subdomain observed",
+                    detail: host,
+                    severity: .low
+                ))
+            }
+        }
+        return events
+    }
+
+    private static func provider(_ name: String, confidence: ConfidenceLevel, evidence: [String]) -> InferredProviderFingerprint {
+        .init(name: name, confidence: confidence, evidence: evidence)
+    }
+
+    private static func providerEvidence(headerMap: [String: String], dnsProviders: [String], matches: [String]) -> [String] {
+        var evidence: [String] = []
+        for match in matches {
+            if headerMap.keys.contains(match) || headerMap.values.contains(where: { $0.contains(match) }) {
+                evidence.append("HTTP \(match)")
+            }
+            if dnsProviders.contains(where: { $0.lowercased().contains(match) }) {
+                evidence.append("DNS \(match)")
+            }
+        }
+        return Array(Set(evidence)).sorted()
+    }
+
+    private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
+        sections
+            .first(where: { $0.recordType == type })?
+            .records
+            .map(\.value) ?? []
+    }
+
+    private static func normalized(_ values: [String]) -> [String] {
+        values.map { $0.lowercased() }.sorted()
+    }
+
+    private static func containsAny(in values: [String], matching patterns: [String]) -> Bool {
+        values.contains { value in
+            let normalized = value.lowercased()
+            return patterns.contains { normalized.contains($0) }
+        }
+    }
+
+    private static func containsHeaderValue(_ headerMap: [String: String], value: String) -> Bool {
+        headerMap.values.contains(where: { $0.contains(value) })
+    }
+
+    private static func apexDomain(for host: String) -> String {
+        let parts = host.split(separator: ".")
+        guard parts.count > 2 else { return host }
+        return parts.suffix(2).joined(separator: ".")
+    }
+}
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index 752e7f2..f9400f5 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -48,6 +48,14 @@ struct LookupSnapshot {
     let ownershipError: String?
     let ownershipHistory: [DomainOwnershipHistoryEvent]
     let ownershipHistoryError: String?
+    let inferredProvider: InferredProviderFingerprint?
+    let priorProviders: [String]
+    let domainClassification: DomainClassificationSummary?
+    let ownershipTransitions: [OwnershipTransitionEvent]
+    let hostingTransitions: [HostingTransitionEvent]
+    let subdomainHistory: [SubdomainHistoryEntry]
+    let riskSignals: [IntelligenceRiskSignal]
+    let intelligenceTimeline: [IntelligenceTimelineEvent]
     let ptrRecord: String?
     let ptrError: String?
     let redirectChain: [RedirectHop]
@@ -122,6 +130,14 @@ extension HistoryEntry {
             ownershipError: ownershipError,
             ownershipHistory: ownershipHistory,
             ownershipHistoryError: ownershipHistoryError,
+            inferredProvider: inferredProvider,
+            priorProviders: priorProviders,
+            domainClassification: domainClassification,
+            ownershipTransitions: ownershipTransitions,
+            hostingTransitions: hostingTransitions,
+            subdomainHistory: subdomainHistory,
+            riskSignals: riskSignals,
+            intelligenceTimeline: intelligenceTimeline,
             ptrRecord: ptrRecord,
             ptrError: ptrError,
             redirectChain: redirectChain,