krz/domain-dig

an ios app for DNS & SSL analysis

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

f045a5d339a796f40a9e958436ebc6f5314bdab8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-24T03:15:20Z

feat(v3.4.0): add backup, restore, and data portability

• introduce versioned DomainDig backup format
• add full backup export/import with merge and replace modes
• implement data validation and conflict handling
• add partial exports for tracked domains, workflows, and history
• add Data Portability settings section
• support Files/iCloud Drive import and export
• harden migrations for evolving local models
• extend CLI with backup export and validation
 DomainDataPortabilityService.swift       | 1397 ++++++++++++++++++++++++++++++
 DomainDig.xcodeproj/project.pbxproj      |   16 +-
 DomainDig/AppVersion.swift               |    2 +-
 DomainDig/ContentView.swift              |  377 +++++++-
 DomainDig/DomainDigApp.swift             |   13 +
 DomainDig/DomainMonitoringService.swift  |  613 +++++++++++++
 DomainDig/DomainViewModel.swift          |  295 +++++--
 DomainDig/FeatureAccessService.swift     |   19 +-
 DomainDig/Info.plist                     |    8 +
 DomainDig/LocalNotificationService.swift |   33 +
 DomainDig/Models.swift                   |  223 ++++-
 DomainDig/MonitoringView.swift           |  198 +++++
 DomainDig/PaywallView.swift              |    4 +-
 DomainDig/PremiumAccessService.swift     |    6 +-
 DomainDig/RootTabView.swift              |    7 +
 DomainDig/WatchlistView.swift            |   21 +
 DomainDigCLI.swift                       |  186 +++-
 17 files changed, 3326 insertions(+), 92 deletions(-)

diff --git a/DomainDataPortabilityService.swift b/DomainDataPortabilityService.swift
new file mode 100644
index 0000000..fabdfdd
--- /dev/null
+++ b/DomainDataPortabilityService.swift
@@ -0,0 +1,1397 @@
+import Foundation
+import UniformTypeIdentifiers
+
+struct DomainDigBackup: Codable {
+    static let currentSchemaVersion = 1
+
+    let schemaVersion: Int
+    let exportedAt: Date
+    let appVersion: String
+    let trackedDomains: [TrackedDomain]
+    let historyEntries: [HistoryEntry]
+    let workflows: [DomainWorkflow]
+    let monitoringSettings: MonitoringSettings?
+    let monitoringLogs: [MonitoringLog]
+    let appSettings: AppSettingsSnapshot
+    let featureMetadata: FeatureMetadataSnapshot?
+}
+
+struct AppSettingsSnapshot: Codable {
+    let recentSearches: [String]
+    let savedDomains: [String]
+    let resolverURLString: String
+    let appDensityRawValue: String
+}
+
+struct FeatureMetadataSnapshot: Codable {
+    let cachedEntitlement: PurchaseService.CachedEntitlement?
+    let usageCredits: UsageCreditsSnapshot?
+}
+
+struct UsageCreditsSnapshot: Codable {
+    let appVersion: String
+    let remainingByFeature: [UsageCreditFeature: Int]
+}
+
+struct TrackedDomainsExport: Codable {
+    let schemaVersion: Int
+    let exportedAt: Date
+    let appVersion: String
+    let trackedDomains: [TrackedDomain]
+}
+
+struct WorkflowsExport: Codable {
+    let schemaVersion: Int
+    let exportedAt: Date
+    let appVersion: String
+    let workflows: [DomainWorkflow]
+}
+
+struct HistoryExport: Codable {
+    let schemaVersion: Int
+    let exportedAt: Date
+    let appVersion: String
+    let historyEntries: [HistoryEntry]
+}
+
+enum DataPortabilityImportMode: String, CaseIterable, Identifiable {
+    case merge
+    case replace
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .merge:
+            return "Merge"
+        case .replace:
+            return "Replace"
+        }
+    }
+
+    var explanation: String {
+        switch self {
+        case .merge:
+            return "Keep local data and merge imported items."
+        case .replace:
+            return "Replace local data with the imported file."
+        }
+    }
+}
+
+enum DataPortabilityImportKind: String {
+    case backup
+    case trackedDomains
+    case workflows
+}
+
+struct DataLifecycleSummary: Equatable {
+    let trackedDomains: Int
+    let historySnapshots: Int
+    let workflows: Int
+    let cachedItems: Int
+    let monitoringLogs: Int
+}
+
+struct DataValidationMessage: Identifiable, Equatable {
+    let id = UUID()
+    let text: String
+    let isError: Bool
+}
+
+struct DataValidationReport {
+    let messages: [DataValidationMessage]
+
+    var warnings: [String] {
+        messages.filter { !$0.isError }.map(\.text)
+    }
+
+    var errors: [String] {
+        messages.filter(\.isError).map(\.text)
+    }
+}
+
+struct DataImportPreview {
+    let fileName: String
+    let kind: DataPortabilityImportKind
+    let mode: DataPortabilityImportMode
+    let summaryLines: [String]
+    let warnings: [String]
+    let currentCounts: DataLifecycleSummary
+    let projectedCounts: DataLifecycleSummary
+    fileprivate let payload: ImportPayload
+}
+
+struct DataImportResult {
+    let kind: DataPortabilityImportKind
+    let mode: DataPortabilityImportMode
+    let summary: String
+    let warnings: [String]
+}
+
+enum DataPortabilityError: LocalizedError {
+    case unsupportedFormat
+    case unsupportedSchema(Int)
+    case invalidCSV(String)
+    case unreadableFile
+    case validationFailed([String])
+
+    var errorDescription: String? {
+        switch self {
+        case .unsupportedFormat:
+            return "The selected file is not a supported DomainDig import."
+        case .unsupportedSchema(let version):
+            return "Backup schema v\(version) is not supported by this version of DomainDig."
+        case .invalidCSV(let message):
+            return message
+        case .unreadableFile:
+            return "The selected file could not be read."
+        case .validationFailed(let messages):
+            return messages.joined(separator: "\n")
+        }
+    }
+}
+
+enum DataPortabilityCSV {
+    static func trackedDomains(_ domains: [TrackedDomain]) -> String {
+        let header = [
+            "id",
+            "domain",
+            "createdAt",
+            "updatedAt",
+            "note",
+            "isPinned",
+            "monitoringEnabled",
+            "lastKnownAvailability",
+            "certificateWarningLevel",
+            "certificateDaysRemaining",
+            "lastMonitoredAt",
+            "lastAlertAt"
+        ]
+
+        let rows = domains.map { domain in
+            [
+                domain.id.uuidString,
+                domain.domain,
+                DomainDataPortabilityService.formatISODate(domain.createdAt),
+                DomainDataPortabilityService.formatISODate(domain.updatedAt),
+                domain.note ?? "",
+                String(domain.isPinned),
+                String(domain.monitoringEnabled),
+                domain.lastKnownAvailability?.rawValue ?? "",
+                domain.certificateWarningLevel.rawValue,
+                domain.certificateDaysRemaining.map(String.init) ?? "",
+                domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "",
+                domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? ""
+            ]
+        }
+
+        return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
+    }
+
+    static func workflows(_ workflows: [DomainWorkflow]) -> String {
+        let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"]
+        let rows = workflows.map { workflow in
+            [
+                workflow.id.uuidString,
+                workflow.name,
+                workflow.domains.joined(separator: "|"),
+                DomainDataPortabilityService.formatISODate(workflow.createdAt),
+                DomainDataPortabilityService.formatISODate(workflow.updatedAt),
+                workflow.notes ?? ""
+            ]
+        }
+
+        return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
+    }
+
+    static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] {
+        let rows = try parseRows(from: string)
+        guard let header = rows.first else { return [] }
+        let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
+
+        return mappedRows.compactMap { row in
+            let domain = Self.normalizedDomain(row["domain"])
+            guard !domain.isEmpty else { return nil }
+
+            let createdAt = parseDate(row["createdAt"]) ?? Date()
+            let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
+            let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:))
+            let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none
+
+            return TrackedDomain(
+                id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
+                domain: domain,
+                createdAt: createdAt,
+                updatedAt: updatedAt,
+                note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
+                isPinned: Self.parseBool(row["isPinned"]),
+                monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true,
+                lastKnownAvailability: availability,
+                certificateWarningLevel: certificateLevel,
+                certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
+                lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
+                lastAlertAt: parseDate(row["lastAlertAt"])
+            )
+        }
+    }
+
+    static func parseWorkflows(from string: String) throws -> [DomainWorkflow] {
+        let rows = try parseRows(from: string)
+        guard let header = rows.first else { return [] }
+        let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
+
+        return mappedRows.compactMap { row in
+            let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            let domains = (row["domains"] ?? "")
+                .split(separator: "|")
+                .map { Self.normalizedDomain(String($0)) }
+                .filter { !$0.isEmpty }
+
+            guard !name.isEmpty, !domains.isEmpty else { return nil }
+
+            let createdAt = parseDate(row["createdAt"]) ?? Date()
+            let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
+            return DomainWorkflow(
+                id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
+                name: name,
+                domains: Self.deduplicated(domains),
+                createdAt: createdAt,
+                updatedAt: updatedAt,
+                notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+            )
+        }
+    }
+
+    private nonisolated static func parseRows(from string: String) throws -> [[String]] {
+        var rows: [[String]] = []
+        var currentRow: [String] = []
+        var currentField = ""
+        var insideQuotes = false
+
+        for character in string {
+            switch character {
+            case "\"":
+                insideQuotes.toggle()
+            case "," where !insideQuotes:
+                currentRow.append(currentField)
+                currentField = ""
+            case "\n" where !insideQuotes:
+                currentRow.append(currentField)
+                rows.append(currentRow)
+                currentRow = []
+                currentField = ""
+            case "\r":
+                continue
+            default:
+                currentField.append(character)
+            }
+        }
+
+        if insideQuotes {
+            throw DataPortabilityError.invalidCSV("The CSV file is malformed.")
+        }
+
+        if !currentField.isEmpty || !currentRow.isEmpty {
+            currentRow.append(currentField)
+            rows.append(currentRow)
+        }
+
+        return rows.filter { !$0.allSatisfy(\.isEmpty) }
+    }
+
+    private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] {
+        Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) })
+    }
+
+    private nonisolated static func csvLine(_ values: [String]) -> String {
+        values.map { value in
+            let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
+            return "\"\(escaped)\""
+        }
+        .joined(separator: ",")
+    }
+
+    private nonisolated static func parseDate(_ value: String?) -> Date? {
+        guard let value, !value.isEmpty else { return nil }
+        return DomainDataPortabilityService.parseISODate(value)
+    }
+
+    private nonisolated static func parseBool(_ value: String?) -> Bool {
+        guard let value else { return false }
+        return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
+    }
+
+    private nonisolated static func normalizedDomain(_ value: String?) -> String {
+        value?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/")
+            .first?
+            .lowercased() ?? ""
+    }
+
+    private nonisolated static func deduplicated(_ values: [String]) -> [String] {
+        var seen = Set<String>()
+        return values.filter { seen.insert($0).inserted }
+    }
+}
+
+enum DataMigrationService {
+    private static let migrationMarkerKey = "data.migrations.v3_4_0"
+
+    static func migrateIfNeeded(defaults: UserDefaults = .standard) {
+        guard !defaults.bool(forKey: migrationMarkerKey) else { return }
+
+        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
+        DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
+
+        let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
+        DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
+
+        let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
+        DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
+
+        let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
+        let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
+        DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
+
+        let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
+        DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
+
+        defaults.set(true, forKey: migrationMarkerKey)
+    }
+}
+
+enum DataValidationService {
+    static func validate(backup: DomainDigBackup) -> DataValidationReport {
+        var messages: [DataValidationMessage] = []
+
+        if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
+            messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
+        } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
+            messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
+        }
+
+        messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
+        messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
+        messages.append(contentsOf: validateWorkflows(backup.workflows))
+
+        if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
+        }
+
+        return DataValidationReport(messages: messages)
+    }
+
+    static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
+        DataValidationReport(messages: validateTrackedDomains(trackedDomains))
+    }
+
+    static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
+        DataValidationReport(messages: validateWorkflows(workflows))
+    }
+
+    private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
+        var messages: [DataValidationMessage] = []
+        var seen = Set<String>()
+
+        for trackedDomain in trackedDomains {
+            let normalized = normalizeDomain(trackedDomain.domain)
+            if normalized.isEmpty {
+                messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
+                continue
+            }
+
+            if !seen.insert(normalized).inserted {
+                messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
+            }
+        }
+
+        return messages
+    }
+
+    private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
+        var messages: [DataValidationMessage] = []
+        for entry in historyEntries {
+            if normalizeDomain(entry.domain).isEmpty {
+                messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
+            }
+            if entry.timestamp == .distantPast {
+                messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
+            }
+            for issue in entry.validationIssues {
+                messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
+            }
+        }
+        return messages
+    }
+
+    private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
+        var messages: [DataValidationMessage] = []
+        for workflow in workflows {
+            if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                messages.append(.init(text: "A workflow is missing its name.", isError: true))
+            }
+            if workflow.domains.isEmpty {
+                messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
+            }
+            if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
+                messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
+            }
+        }
+        return messages
+    }
+
+    private nonisolated static func normalizeDomain(_ value: String) -> String {
+        value
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/")
+            .first?
+            .lowercased() ?? ""
+    }
+}
+
+enum DomainDataPortabilityService {
+    static let backupUTType = UTType.json
+    static let csvUTType = UTType.commaSeparatedText
+
+    fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        return formatter.string(from: date)
+    }
+
+    fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        return formatter.date(from: value)
+    }
+
+    private enum StorageKey {
+        static let recentSearches = "recentSearches"
+        static let savedDomains = "savedDomains"
+        static let trackedDomains = "trackedDomains"
+        static let legacyWatchedDomains = "watchedDomains"
+        static let history = "lookupHistory"
+        static let workflows = "domainWorkflows"
+        static let monitoringSettings = "monitoring.settings"
+        static let monitoringLogs = "monitoring.logs"
+        static let appDensity = AppDensity.userDefaultsKey
+        static let resolverURL = DNSResolverOption.userDefaultsKey
+        static let purchaseCache = "purchase.cachedEntitlement"
+        static let usageCredits = "usageCredits.ledger"
+    }
+
+    static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
+        defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
+    }
+
+    static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
+        defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
+    }
+
+    static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
+        defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
+    }
+
+    static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
+        defaults.set(values, forKey: StorageKey.savedDomains)
+    }
+
+    static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
+        let decoder = JSONDecoder()
+
+        if let data = defaults.data(forKey: StorageKey.trackedDomains),
+           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
+            return deduplicatedTrackedDomains(domains)
+        }
+
+        if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
+           let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
+            return deduplicatedTrackedDomains(
+                legacy.map {
+                    TrackedDomain(
+                        id: $0.id,
+                        domain: normalizeDomain($0.domain),
+                        createdAt: $0.createdAt,
+                        updatedAt: $0.createdAt,
+                        lastKnownAvailability: $0.lastKnownAvailability
+                    )
+                }
+            )
+        }
+
+        return []
+    }
+
+    static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
+        if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
+            defaults.set(data, forKey: StorageKey.trackedDomains)
+        }
+        defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
+    }
+
+    static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
+        guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
+
+        if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
+            return deduplicatedHistoryEntries(entries)
+        }
+
+        guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
+            return []
+        }
+
+        let decoder = JSONDecoder()
+        let entries = rawArray.compactMap { item -> HistoryEntry? in
+            guard JSONSerialization.isValidJSONObject(item),
+                  let entryData = try? JSONSerialization.data(withJSONObject: item) else {
+                return nil
+            }
+            return try? decoder.decode(HistoryEntry.self, from: entryData)
+        }
+        return deduplicatedHistoryEntries(entries)
+    }
+
+    static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
+        if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
+            defaults.set(data, forKey: StorageKey.history)
+        }
+    }
+
+    static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
+        guard let data = defaults.data(forKey: StorageKey.workflows),
+              let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
+            return []
+        }
+
+        return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
+    }
+
+    static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
+        if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
+            defaults.set(data, forKey: StorageKey.workflows)
+        }
+    }
+
+    static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
+        guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
+              let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
+            return MonitoringSettings()
+        }
+        return settings
+    }
+
+    static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
+        if let data = try? JSONEncoder().encode(settings) {
+            defaults.set(data, forKey: StorageKey.monitoringSettings)
+        }
+    }
+
+    static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
+        guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
+              let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
+            return []
+        }
+        return Array(logs.prefix(MonitoringStorage.maxLogs))
+    }
+
+    static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
+        if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
+            defaults.set(data, forKey: StorageKey.monitoringLogs)
+        }
+    }
+
+    static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
+        AppSettingsSnapshot(
+            recentSearches: loadRecentSearches(defaults: defaults),
+            savedDomains: loadSavedDomains(defaults: defaults),
+            resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
+            appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
+        )
+    }
+
+    static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
+        saveRecentSearches(settings.recentSearches, defaults: defaults)
+        saveSavedDomains(settings.savedDomains, defaults: defaults)
+        defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
+        defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
+    }
+
+    static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
+        let cachedEntitlement: PurchaseService.CachedEntitlement?
+        if let data = defaults.data(forKey: StorageKey.purchaseCache) {
+            cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
+        } else {
+            cachedEntitlement = nil
+        }
+
+        let usageCredits: UsageCreditsSnapshot?
+        if let data = defaults.data(forKey: StorageKey.usageCredits) {
+            usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
+        } else {
+            usageCredits = nil
+        }
+
+        return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
+    }
+
+    static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
+        if let cachedEntitlement = featureMetadata?.cachedEntitlement,
+           let data = try? JSONEncoder().encode(cachedEntitlement) {
+            defaults.set(data, forKey: StorageKey.purchaseCache)
+        }
+
+        if let usageCredits = featureMetadata?.usageCredits,
+           let data = try? JSONEncoder().encode(usageCredits) {
+            defaults.set(data, forKey: StorageKey.usageCredits)
+        }
+    }
+
+    static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
+        DataMigrationService.migrateIfNeeded(defaults: defaults)
+        return DomainDigBackup(
+            schemaVersion: DomainDigBackup.currentSchemaVersion,
+            exportedAt: Date(),
+            appVersion: AppVersion.current,
+            trackedDomains: loadTrackedDomains(defaults: defaults),
+            historyEntries: loadHistoryEntries(defaults: defaults),
+            workflows: loadWorkflows(defaults: defaults),
+            monitoringSettings: loadMonitoringSettings(defaults: defaults),
+            monitoringLogs: loadMonitoringLogs(defaults: defaults),
+            appSettings: loadAppSettings(defaults: defaults),
+            featureMetadata: loadFeatureMetadata(defaults: defaults)
+        )
+    }
+
+    static func backupData(defaults: UserDefaults = .standard) throws -> Data {
+        try makeEncoder().encode(currentBackup(defaults: defaults))
+    }
+
+    static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
+        try makeEncoder().encode(
+            TrackedDomainsExport(
+                schemaVersion: DomainDigBackup.currentSchemaVersion,
+                exportedAt: Date(),
+                appVersion: AppVersion.current,
+                trackedDomains: loadTrackedDomains(defaults: defaults)
+            )
+        )
+    }
+
+    static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
+        try makeEncoder().encode(
+            WorkflowsExport(
+                schemaVersion: DomainDigBackup.currentSchemaVersion,
+                exportedAt: Date(),
+                appVersion: AppVersion.current,
+                workflows: loadWorkflows(defaults: defaults)
+            )
+        )
+    }
+
+    static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
+        try makeEncoder().encode(
+            HistoryExport(
+                schemaVersion: DomainDigBackup.currentSchemaVersion,
+                exportedAt: Date(),
+                appVersion: AppVersion.current,
+                historyEntries: loadHistoryEntries(defaults: defaults)
+            )
+        )
+    }
+
+    static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
+        DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
+    }
+
+    static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
+        DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
+    }
+
+    static func prepareImport(
+        data: Data,
+        fileName: String,
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults = .standard
+    ) throws -> DataImportPreview {
+        DataMigrationService.migrateIfNeeded(defaults: defaults)
+        let currentCounts = lifecycleSummary(defaults: defaults)
+
+        let payload = try decodeImportPayload(from: data, fileName: fileName)
+        let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
+
+        return DataImportPreview(
+            fileName: fileName,
+            kind: payload.kind,
+            mode: mode,
+            summaryLines: payload.summaryLines(mode: mode),
+            warnings: payload.validationReport.warnings,
+            currentCounts: currentCounts,
+            projectedCounts: projectedCounts,
+            payload: payload
+        )
+    }
+
+    static func applyImport(
+        _ preview: DataImportPreview,
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults = .standard
+    ) throws -> DataImportResult {
+        switch preview.payload {
+        case .backup(let backup, let report):
+            try applyBackup(backup, mode: mode, defaults: defaults)
+            return DataImportResult(
+                kind: .backup,
+                mode: mode,
+                summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.",
+                warnings: report.warnings
+            )
+        case .trackedDomains(let trackedDomains, let report):
+            applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
+            return DataImportResult(
+                kind: .trackedDomains,
+                mode: mode,
+                summary: "Imported \(trackedDomains.count) tracked domains.",
+                warnings: report.warnings
+            )
+        case .workflows(let workflows, let report):
+            applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
+            return DataImportResult(
+                kind: .workflows,
+                mode: mode,
+                summary: "Imported \(workflows.count) workflows.",
+                warnings: report.warnings
+            )
+        }
+    }
+
+    static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
+        switch try decodeImportPayload(from: data, fileName: fileName) {
+        case .backup(_, let report):
+            return report
+        case .trackedDomains(_, let report):
+            return report
+        case .workflows(_, let report):
+            return report
+        }
+    }
+
+    static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
+        let appSettings = loadAppSettings(defaults: defaults)
+        let featureMetadata = loadFeatureMetadata(defaults: defaults)
+        let cachedItems = appSettings.recentSearches.count
+            + appSettings.savedDomains.count
+            + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
+            + (featureMetadata.usageCredits == nil ? 0 : 1)
+
+        return DataLifecycleSummary(
+            trackedDomains: loadTrackedDomains(defaults: defaults).count,
+            historySnapshots: loadHistoryEntries(defaults: defaults).count,
+            workflows: loadWorkflows(defaults: defaults).count,
+            cachedItems: cachedItems,
+            monitoringLogs: loadMonitoringLogs(defaults: defaults).count
+        )
+    }
+
+    private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
+        let decoder = JSONDecoder()
+
+        if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
+            if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
+                throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
+            }
+            let report = DataValidationService.validate(backup: backup)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .backup(backup, report)
+        }
+
+        if let backup = try? decodeLegacyBackup(from: data) {
+            let report = DataValidationService.validate(backup: backup)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .backup(backup, report)
+        }
+
+        if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
+            let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .trackedDomains(export.trackedDomains, report)
+        }
+
+        if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
+            let report = DataValidationService.validate(workflows: export.workflows)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .workflows(export.workflows, report)
+        }
+
+        if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
+            let report = DataValidationService.validate(trackedDomains: trackedDomains)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .trackedDomains(trackedDomains, report)
+        }
+
+        if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
+            let report = DataValidationService.validate(workflows: workflows)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .workflows(workflows, report)
+        }
+
+        let lowercasedFileName = fileName.lowercased()
+        if lowercasedFileName.hasSuffix(".csv") {
+            guard let string = String(data: data, encoding: .utf8) else {
+                throw DataPortabilityError.unreadableFile
+            }
+            if lowercasedFileName.contains("workflow") {
+                let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
+                let report = DataValidationService.validate(workflows: workflows)
+                if !report.errors.isEmpty {
+                    throw DataPortabilityError.validationFailed(report.errors)
+                }
+                return .workflows(workflows, report)
+            }
+
+            let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
+            let report = DataValidationService.validate(trackedDomains: trackedDomains)
+            if !report.errors.isEmpty {
+                throw DataPortabilityError.validationFailed(report.errors)
+            }
+            return .trackedDomains(trackedDomains, report)
+        }
+
+        throw DataPortabilityError.unsupportedFormat
+    }
+
+    private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
+        struct LegacyBackup: Decodable {
+            let trackedDomains: [TrackedDomain]?
+            let historyEntries: [HistoryEntry]?
+            let workflows: [DomainWorkflow]?
+            let monitoringSettings: MonitoringSettings?
+            let monitoringLogs: [MonitoringLog]?
+            let recentSearches: [String]?
+            let savedDomains: [String]?
+        }
+
+        let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
+        return DomainDigBackup(
+            schemaVersion: 0,
+            exportedAt: Date(),
+            appVersion: AppVersion.current,
+            trackedDomains: legacy.trackedDomains ?? [],
+            historyEntries: legacy.historyEntries ?? [],
+            workflows: legacy.workflows ?? [],
+            monitoringSettings: legacy.monitoringSettings,
+            monitoringLogs: legacy.monitoringLogs ?? [],
+            appSettings: AppSettingsSnapshot(
+                recentSearches: legacy.recentSearches ?? [],
+                savedDomains: legacy.savedDomains ?? [],
+                resolverURLString: DNSResolverOption.defaultURLString,
+                appDensityRawValue: AppDensity.compact.rawValue
+            ),
+            featureMetadata: nil
+        )
+    }
+
+    private static func applyBackup(
+        _ backup: DomainDigBackup,
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults
+    ) throws {
+        if mode == .replace {
+            saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
+            saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
+            saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
+            saveMonitoringSettings(
+                MonitoringStorage.sanitizeSettings(
+                    backup.monitoringSettings ?? MonitoringSettings(),
+                    trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
+                ),
+                defaults: defaults
+            )
+            saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
+            saveAppSettings(backup.appSettings, defaults: defaults)
+            saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
+            return
+        }
+
+        let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
+        let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
+        saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
+
+        let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
+        let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
+        saveHistoryEntries(
+            mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
+            defaults: defaults
+        )
+
+        let existingWorkflows = loadWorkflows(defaults: defaults)
+        saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
+
+        let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
+        saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
+
+        let remappedMonitoringSettings = remapMonitoringSettings(
+            backup.monitoringSettings ?? MonitoringSettings(),
+            using: mergedTrackedDomains.idMap
+        )
+        saveMonitoringSettings(
+            MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
+            defaults: defaults
+        )
+
+        let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
+        saveAppSettings(mergedSettings, defaults: defaults)
+
+        let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
+        saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
+    }
+
+    private static func applyTrackedDomainsImport(
+        _ trackedDomains: [TrackedDomain],
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults
+    ) {
+        let importedDomains = deduplicatedTrackedDomains(trackedDomains)
+        let finalDomains: [TrackedDomain]
+        switch mode {
+        case .merge:
+            finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
+        case .replace:
+            finalDomains = importedDomains
+        }
+
+        saveTrackedDomains(finalDomains, defaults: defaults)
+        let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
+        saveMonitoringSettings(sanitizedSettings, defaults: defaults)
+    }
+
+    private static func applyWorkflowsImport(
+        _ workflows: [DomainWorkflow],
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults
+    ) {
+        let importedWorkflows = deduplicatedWorkflows(workflows)
+        let finalWorkflows: [DomainWorkflow]
+        switch mode {
+        case .merge:
+            finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
+        case .replace:
+            finalWorkflows = importedWorkflows
+        }
+        saveWorkflows(finalWorkflows, defaults: defaults)
+    }
+
+    private static func projectCounts(
+        for payload: ImportPayload,
+        mode: DataPortabilityImportMode,
+        defaults: UserDefaults
+    ) -> DataLifecycleSummary {
+        let current = lifecycleSummary(defaults: defaults)
+        switch payload {
+        case .backup(let backup, _):
+            if mode == .replace {
+                return DataLifecycleSummary(
+                    trackedDomains: backup.trackedDomains.count,
+                    historySnapshots: backup.historyEntries.count,
+                    workflows: backup.workflows.count,
+                    cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
+                    monitoringLogs: backup.monitoringLogs.count
+                )
+            }
+
+            return DataLifecycleSummary(
+                trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
+                historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
+                workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
+                cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
+                monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
+            )
+        case .trackedDomains(let trackedDomains, _):
+            let total = mode == .replace
+                ? trackedDomains.count
+                : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
+            return DataLifecycleSummary(
+                trackedDomains: total,
+                historySnapshots: current.historySnapshots,
+                workflows: current.workflows,
+                cachedItems: current.cachedItems,
+                monitoringLogs: current.monitoringLogs
+            )
+        case .workflows(let workflows, _):
+            let total = mode == .replace
+                ? workflows.count
+                : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
+            return DataLifecycleSummary(
+                trackedDomains: current.trackedDomains,
+                historySnapshots: current.historySnapshots,
+                workflows: total,
+                cachedItems: current.cachedItems,
+                monitoringLogs: current.monitoringLogs
+            )
+        }
+    }
+
+    private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
+        var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
+        var idMap: [UUID: UUID] = [:]
+
+        for trackedDomain in incoming {
+            let key = normalizeDomain(trackedDomain.domain)
+            guard !key.isEmpty else { continue }
+
+            if let existingDomain = mergedByDomain[key] {
+                let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
+                    ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
+                    : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
+                mergedByDomain[key] = chosen
+                idMap[trackedDomain.id] = chosen.id
+            } else {
+                let normalized = normalizedTrackedDomain(trackedDomain)
+                mergedByDomain[key] = normalized
+                idMap[trackedDomain.id] = normalized.id
+            }
+        }
+
+        for trackedDomain in existing {
+            idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
+        }
+
+        return MergedTrackedDomains(
+            domains: mergedByDomain.values.sorted {
+                if $0.updatedAt != $1.updatedAt {
+                    return $0.updatedAt > $1.updatedAt
+                }
+                return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+            },
+            idMap: idMap
+        )
+    }
+
+    private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
+        let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
+        let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
+        let note = winner.note ?? existing.note ?? incoming.note
+
+        return TrackedDomain(
+            id: existing.id,
+            domain: normalizedDomain,
+            createdAt: min(existing.createdAt, incoming.createdAt),
+            updatedAt: max(existing.updatedAt, incoming.updatedAt),
+            note: note,
+            isPinned: existing.isPinned || incoming.isPinned,
+            monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
+            lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
+            lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
+            lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
+            lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
+            certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
+            certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
+            lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
+            lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max()
+        )
+    }
+
+    private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
+        var mergedByKey: [String: HistoryEntry] = [:]
+
+        for entry in existing + incoming {
+            let key = historyKey(for: entry)
+            if let existingEntry = mergedByKey[key] {
+                mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
+            } else {
+                mergedByKey[key] = entry
+            }
+        }
+
+        return mergedByKey.values.sorted { lhs, rhs in
+            if lhs.timestamp != rhs.timestamp {
+                return lhs.timestamp > rhs.timestamp
+            }
+            return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
+        }
+    }
+
+    private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
+        var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
+
+        for workflow in incoming {
+            let normalized = normalizedWorkflow(workflow)
+            if let existingWorkflow = merged[workflow.id] {
+                let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
+                merged[workflow.id] = DomainWorkflow(
+                    id: existingWorkflow.id,
+                    name: winner.name,
+                    domains: deduplicated(existingWorkflow.domains + normalized.domains),
+                    createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
+                    updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
+                    notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
+                )
+            } else {
+                merged[workflow.id] = normalized
+            }
+        }
+
+        return merged.values.sorted(by: workflowSort)
+    }
+
+    private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
+        var merged: [UUID: MonitoringLog] = [:]
+        for log in existing + incoming {
+            if let current = merged[log.id] {
+                merged[log.id] = log.timestamp >= current.timestamp ? log : current
+            } else {
+                merged[log.id] = log
+            }
+        }
+        return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
+    }
+
+    private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
+        AppSettingsSnapshot(
+            recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
+            savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
+            resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
+            appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
+        )
+    }
+
+    private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
+        guard let incoming else { return existing }
+
+        let mergedUsageCredits: UsageCreditsSnapshot?
+        switch (existing.usageCredits, incoming.usageCredits) {
+        case let (.some(lhs), .some(rhs)):
+            let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
+                (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
+            })
+            mergedUsageCredits = UsageCreditsSnapshot(
+                appVersion: rhs.appVersion,
+                remainingByFeature: mergedRemaining
+            )
+        case (.some(let lhs), nil):
+            mergedUsageCredits = lhs
+        case (nil, .some(let rhs)):
+            mergedUsageCredits = rhs
+        case (nil, nil):
+            mergedUsageCredits = nil
+        }
+
+        return FeatureMetadataSnapshot(
+            cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
+            usageCredits: mergedUsageCredits
+        )
+    }
+
+    private static func remapMonitoringSettings(
+        _ settings: MonitoringSettings,
+        using idMap: [UUID: UUID]
+    ) -> MonitoringSettings {
+        var remapped = settings
+        remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
+        return remapped
+    }
+
+    private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
+        var remapped = entry
+        if let trackedDomainID = entry.trackedDomainID {
+            remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
+        }
+        return remapped
+    }
+
+    private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
+        if historyQualityScore(lhs) == historyQualityScore(rhs) {
+            return lhs.timestamp >= rhs.timestamp ? lhs : rhs
+        }
+        return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
+    }
+
+    private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
+        var score = 0
+        score += entry.dnsSections.count
+        score += entry.httpHeaders.count
+        score += entry.reachabilityResults.count
+        score += entry.redirectChain.count
+        score += entry.subdomains.count
+        score += entry.portScanResults.count
+        score += entry.validationIssues.isEmpty ? 2 : 0
+        score += entry.sslInfo == nil ? 0 : 4
+        score += entry.ownership == nil ? 0 : 4
+        score += entry.emailSecurity == nil ? 0 : 3
+        score += entry.ipGeolocation == nil ? 0 : 2
+        return score
+    }
+
+    private static func historyKey(for entry: HistoryEntry) -> String {
+        if entry.id != UUID() {
+            return "id:\(entry.id.uuidString)"
+        }
+        return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
+    }
+
+    private nonisolated static func normalizeDomain(_ value: String) -> String {
+        value
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/")
+            .first?
+            .lowercased() ?? ""
+    }
+
+    private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
+        TrackedDomain(
+            id: trackedDomain.id,
+            domain: normalizeDomain(trackedDomain.domain),
+            createdAt: trackedDomain.createdAt,
+            updatedAt: trackedDomain.updatedAt,
+            note: trackedDomain.note,
+            isPinned: trackedDomain.isPinned,
+            monitoringEnabled: trackedDomain.monitoringEnabled,
+            lastKnownAvailability: trackedDomain.lastKnownAvailability,
+            lastSnapshotID: trackedDomain.lastSnapshotID,
+            lastChangeSummary: trackedDomain.lastChangeSummary,
+            lastChangeSeverity: trackedDomain.lastChangeSeverity,
+            certificateWarningLevel: trackedDomain.certificateWarningLevel,
+            certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
+            lastMonitoredAt: trackedDomain.lastMonitoredAt,
+            lastAlertAt: trackedDomain.lastAlertAt
+        )
+    }
+
+    private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
+        DomainWorkflow(
+            id: workflow.id,
+            name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
+            domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
+            createdAt: workflow.createdAt,
+            updatedAt: workflow.updatedAt,
+            notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        )
+    }
+
+    private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
+        mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
+    }
+
+    private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
+        mergeHistoryEntries(existing: [], incoming: historyEntries)
+    }
+
+    private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
+        mergeWorkflows(existing: [], incoming: workflows)
+    }
+
+    private nonisolated static func deduplicated(_ values: [String]) -> [String] {
+        var seen = Set<String>()
+        return values.filter { seen.insert($0).inserted }
+    }
+
+    private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
+        var seen = Set<UUID>()
+        return values.filter { seen.insert($0).inserted }
+    }
+
+    private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
+        if lhs.updatedAt != rhs.updatedAt {
+            return lhs.updatedAt > rhs.updatedAt
+        }
+        return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+    }
+
+    private nonisolated static func higherCertificateWarningLevel(
+        _ lhs: CertificateWarningLevel,
+        _ rhs: CertificateWarningLevel
+    ) -> CertificateWarningLevel {
+        let rank: [CertificateWarningLevel: Int] = [
+            .none: 0,
+            .warning: 1,
+            .critical: 2
+        ]
+        return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
+    }
+
+    private static func makeEncoder() -> JSONEncoder {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        return encoder
+    }
+}
+
+private struct MergedTrackedDomains {
+    let domains: [TrackedDomain]
+    let idMap: [UUID: UUID]
+}
+
+private enum ImportPayload {
+    case backup(DomainDigBackup, DataValidationReport)
+    case trackedDomains([TrackedDomain], DataValidationReport)
+    case workflows([DomainWorkflow], DataValidationReport)
+
+    var kind: DataPortabilityImportKind {
+        switch self {
+        case .backup:
+            return .backup
+        case .trackedDomains:
+            return .trackedDomains
+        case .workflows:
+            return .workflows
+        }
+    }
+
+    var validationReport: DataValidationReport {
+        switch self {
+        case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
+            return report
+        }
+    }
+
+    func summaryLines(mode: DataPortabilityImportMode) -> [String] {
+        switch self {
+        case .backup(let backup, _):
+            return [
+                "Full backup import",
+                "\(backup.trackedDomains.count) tracked domains",
+                "\(backup.historyEntries.count) history snapshots",
+                "\(backup.workflows.count) workflows",
+                "\(backup.monitoringLogs.count) monitoring logs",
+                mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
+            ]
+        case .trackedDomains(let trackedDomains, _):
+            return [
+                "Tracked domains import",
+                "\(trackedDomains.count) tracked domains",
+                mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
+            ]
+        case .workflows(let workflows, _):
+            return [
+                "Workflow import",
+                "\(workflows.count) workflows",
+                mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
+            ]
+        }
+    }
+}
+
+private extension String {
+    var nilIfEmpty: String? {
+        isEmpty ? nil : self
+    }
+}
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index fc06760..22c4dfc 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -16,6 +16,8 @@
 		8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
 		8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
 		8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
+		8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
+		8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXFileReference section */
@@ -26,6 +28,7 @@
 		8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
 		8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
 		8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
+		8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -95,6 +98,7 @@
 				8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
 				8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
 				8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
+				8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
 			);
 			sourceTree = "<group>";
 		};
@@ -206,6 +210,7 @@
 				8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
 				8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
 				8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
+				8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
 				8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -215,6 +220,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
+				8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
 				8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
 				8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
 				8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
@@ -354,7 +360,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 26;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +377,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.0;
+				MARKETING_VERSION = 3.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +396,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 26;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +413,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.0;
+				MARKETING_VERSION = 3.4.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -426,6 +432,7 @@
 				COPY_PHASE_STRIP = NO;
 				GCC_DYNAMIC_NO_PIC = NO;
 				GCC_OPTIMIZATION_LEVEL = 0;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				PRODUCT_NAME = domaindig;
 			};
 			name = Debug;
@@ -435,6 +442,7 @@
 			buildSettings = {
 				COPY_PHASE_STRIP = YES;
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
 				PRODUCT_NAME = domaindig;
 			};
 			name = Release;
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index f28a017..346beb4 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     nonisolated static var current: String {
-        "3.2.0"
+        "3.4.0"
     }
 }
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index a4e8db3..e1d0a99 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -1,5 +1,6 @@
 import MapKit
 import SwiftUI
+import UniformTypeIdentifiers
 
 enum LookupInputMode: String, CaseIterable, Identifiable {
     case single
@@ -2431,6 +2432,13 @@ struct SettingsView: View {
     @State private var showClearCacheConfirmation = false
     @State private var showClearWorkflowsConfirmation = false
     @State private var showClearTrackedDomainsConfirmation = false
+    @State private var importMode: DataPortabilityImportMode = .merge
+    @State private var showBackupImporter = false
+    @State private var showTrackedDomainsImporter = false
+    @State private var showWorkflowsImporter = false
+    @State private var pendingImportPreview: DataImportPreview?
+    @State private var pendingImportError: String?
+    @State private var showReplaceImportConfirmation = false
 
     private var customResolverError: String? {
         guard resolverOption == .custom else {
@@ -2472,6 +2480,161 @@ struct SettingsView: View {
                 }
             }
 
+            Section("Monitoring") {
+                Toggle(
+                    "Enable Background Monitoring",
+                    isOn: Binding(
+                        get: { viewModel.monitoringSettings.isEnabled },
+                        set: { viewModel.setMonitoringEnabled($0) }
+                    )
+                )
+
+                Picker(
+                    "Frequency",
+                    selection: Binding(
+                        get: { viewModel.monitoringSettings.frequency },
+                        set: { viewModel.setMonitoringFrequency($0) }
+                    )
+                ) {
+                    ForEach(MonitoringFrequency.allCases) { frequency in
+                        Text(frequency.title).tag(frequency)
+                    }
+                }
+
+                Picker(
+                    "Domains",
+                    selection: Binding(
+                        get: { viewModel.monitoringSettings.scope },
+                        set: { viewModel.setMonitoringScope($0) }
+                    )
+                ) {
+                    ForEach(MonitoringScope.allCases) { scope in
+                        Text(scope.title).tag(scope)
+                    }
+                }
+
+                if viewModel.monitoringSettings.scope == .selectedOnly {
+                    ForEach(viewModel.trackedDomains) { trackedDomain in
+                        Toggle(
+                            trackedDomain.domain,
+                            isOn: Binding(
+                                get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
+                                set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
+                            )
+                        )
+                    }
+                }
+
+                Toggle(
+                    "Local Alerts",
+                    isOn: Binding(
+                        get: { viewModel.monitoringSettings.alertsEnabled },
+                        set: { isEnabled in
+                            if isEnabled {
+                                Task {
+                                    await viewModel.requestMonitoringNotificationAuthorization()
+                                }
+                            } else {
+                                viewModel.setMonitoringAlertsEnabled(false)
+                            }
+                        }
+                    )
+                )
+
+                Picker(
+                    "Notify For",
+                    selection: Binding(
+                        get: { viewModel.monitoringSettings.alertFilter },
+                        set: { viewModel.setMonitoringAlertFilter($0) }
+                    )
+                ) {
+                    ForEach(MonitoringAlertFilter.allCases) { filter in
+                        Text(filter.title).tag(filter)
+                    }
+                }
+
+                LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
+                LabeledContent("Notification Access", value: notificationAuthorizationLabel)
+
+                if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
+                    Text(monitoringStatusMessage)
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.secondary)
+                }
+
+                if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
+                    Text("Background monitoring and alerts are available in Pro.")
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            Section("Data Portability") {
+                Picker("Import Mode", selection: $importMode) {
+                    ForEach(DataPortabilityImportMode.allCases) { mode in
+                        Text(mode.title).tag(mode)
+                    }
+                }
+
+                Text(importMode.explanation)
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+
+                Button("Export Full Backup") {
+                    exportFullBackup()
+                }
+
+                Button("Import Backup") {
+                    showBackupImporter = true
+                }
+
+                Menu("Export Tracked Domains") {
+                    Button("JSON") {
+                        exportPortableTrackedDomainsJSON()
+                    }
+                    Button("CSV") {
+                        exportPortableTrackedDomainsCSV()
+                    }
+                }
+
+                Button("Import Tracked Domains") {
+                    showTrackedDomainsImporter = true
+                }
+
+                Menu("Export Workflows") {
+                    Button("JSON") {
+                        exportPortableWorkflowsJSON()
+                    }
+                    Button("CSV") {
+                        exportPortableWorkflowsCSV()
+                    }
+                }
+
+                Button("Import Workflows") {
+                    showWorkflowsImporter = true
+                }
+
+                Button("Export History") {
+                    exportPortableHistoryJSON()
+                }
+
+                LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
+                LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
+                LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
+                LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
+                LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
+
+                Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+
+                if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
+                    Text(portabilityStatusMessage)
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.secondary)
+                }
+            }
+
             Section("Data") {
                 Button("Clear History", role: .destructive) {
                     showClearHistoryConfirmation = true
@@ -2551,7 +2714,7 @@ struct SettingsView: View {
             Section("About") {
                 LabeledContent("Version", value: appVersion)
                 LabeledContent("Storage", value: "Local-only")
-                LabeledContent("Report Schema", value: "3.2.0")
+                LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
             }
         }
         .navigationTitle("Settings")
@@ -2587,12 +2750,73 @@ struct SettingsView: View {
         } message: {
             Text("This removes the watchlist only. History and workflows stay intact.")
         }
+        .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
+            Button("Replace", role: .destructive) {
+                applyPendingImport()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
+        }
+        .alert("Import Error", isPresented: Binding(
+            get: { pendingImportError != nil },
+            set: { if !$0 { pendingImportError = nil } }
+        )) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(pendingImportError ?? "The import could not be completed.")
+        }
+        .sheet(isPresented: Binding(
+            get: { pendingImportPreview != nil },
+            set: { if !$0 { pendingImportPreview = nil } }
+        )) {
+            if let pendingImportPreview {
+                DataImportPreviewSheet(
+                    preview: pendingImportPreview,
+                    mode: importMode,
+                    onCancel: {
+                        self.pendingImportPreview = nil
+                    },
+                    onApply: {
+                        if importMode == .replace {
+                            showReplaceImportConfirmation = true
+                        } else {
+                            applyPendingImport()
+                        }
+                    }
+                )
+            }
+        }
+        .fileImporter(
+            isPresented: $showBackupImporter,
+            allowedContentTypes: [UTType.json],
+            allowsMultipleSelection: false
+        ) { result in
+            handleImportResult(result, expectedKind: .backup)
+        }
+        .fileImporter(
+            isPresented: $showTrackedDomainsImporter,
+            allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
+            allowsMultipleSelection: false
+        ) { result in
+            handleImportResult(result, expectedKind: .trackedDomains)
+        }
+        .fileImporter(
+            isPresented: $showWorkflowsImporter,
+            allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
+            allowsMultipleSelection: false
+        ) { result in
+            handleImportResult(result, expectedKind: .workflows)
+        }
         .onAppear {
             let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
             resolverOption = DNSResolverOption.option(for: currentResolverURL)
             customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
+            viewModel.refreshMonitoringState()
+            viewModel.refreshDataLifecycleSummary()
             Task {
                 await viewModel.refreshUsageCredits()
+                await viewModel.refreshMonitoringAuthorizationStatus()
             }
         }
         .onChange(of: resolverOption) { _, newValue in
@@ -2611,6 +2835,157 @@ struct SettingsView: View {
     private var appVersion: String {
         AppVersion.current
     }
+
+    private var notificationAuthorizationLabel: String {
+        switch viewModel.monitoringNotificationStatus {
+        case .authorized, .provisional, .ephemeral:
+            return "Allowed"
+        case .denied:
+            return "Denied"
+        case .notDetermined:
+            return "Not Requested"
+        @unknown default:
+            return "Unknown"
+        }
+    }
+
+    private func exportFullBackup() {
+        guard let data = viewModel.exportFullBackupData() else { return }
+        ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
+    }
+
+    private func exportPortableTrackedDomainsJSON() {
+        guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
+        ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
+    }
+
+    private func exportPortableTrackedDomainsCSV() {
+        ExportPresenter.share(
+            filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
+            contents: viewModel.exportPortableTrackedDomainsCSV()
+        )
+    }
+
+    private func exportPortableWorkflowsJSON() {
+        guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
+        ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
+    }
+
+    private func exportPortableWorkflowsCSV() {
+        ExportPresenter.share(
+            filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
+            contents: viewModel.exportPortableWorkflowsCSV()
+        )
+    }
+
+    private func exportPortableHistoryJSON() {
+        guard let data = viewModel.exportPortableHistoryJSONData() else { return }
+        ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
+    }
+
+    private func handleImportResult(
+        _ result: Result<[URL], Error>,
+        expectedKind: DataPortabilityImportKind
+    ) {
+        do {
+            guard let url = try result.get().first else { return }
+            let shouldStopAccessing = url.startAccessingSecurityScopedResource()
+            defer {
+                if shouldStopAccessing {
+                    url.stopAccessingSecurityScopedResource()
+                }
+            }
+
+            let data = try Data(contentsOf: url)
+            let preview = try viewModel.prepareDataImport(
+                data: data,
+                fileName: url.lastPathComponent,
+                mode: importMode
+            )
+
+            guard preview.kind == expectedKind else {
+                pendingImportError = preview.kind == .backup
+                    ? "That file is a full backup. Use Import Backup."
+                    : "That file type does not match this import action."
+                return
+            }
+
+            pendingImportPreview = preview
+        } catch {
+            pendingImportError = error.localizedDescription
+        }
+    }
+
+    private func applyPendingImport() {
+        guard let pendingImportPreview else { return }
+        do {
+            _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
+            self.pendingImportPreview = nil
+        } catch {
+            pendingImportError = error.localizedDescription
+        }
+    }
+
+    private func portabilityFilename(suffix: String, fileExtension: String) -> String {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
+        return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
+    }
+}
+
+private struct DataImportPreviewSheet: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let preview: DataImportPreview
+    let mode: DataPortabilityImportMode
+    let onCancel: () -> Void
+    let onApply: () -> Void
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section("Summary") {
+                    ForEach(preview.summaryLines, id: \.self) { line in
+                        Text(line)
+                    }
+                }
+
+                Section("Projected Counts") {
+                    LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
+                    LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
+                    LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
+                    LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
+                    LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
+                }
+
+                if !preview.warnings.isEmpty {
+                    Section("Warnings") {
+                        ForEach(preview.warnings, id: \.self) { warning in
+                            Text(warning)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Import Preview")
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(mode == .replace ? "Replace" : "Import") {
+                        onApply()
+                        if mode == .merge {
+                            dismiss()
+                        }
+                    }
+                }
+            }
+        }
+    }
 }
 
 #Preview {
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index ae81654..f7cd15e 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -9,12 +9,14 @@ import SwiftUI
 
 @main
 struct DomainDigApp: App {
+    @Environment(\.scenePhase) private var scenePhase
     @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
     @State private var viewModel = DomainViewModel()
     @State private var purchaseService = PurchaseService.shared
 
     init() {
         LocalNotificationService.shared.configureForegroundPresentation()
+        DomainMonitoringScheduler.shared.registerBackgroundTask()
     }
 
     var body: some Scene {
@@ -24,7 +26,18 @@ struct DomainDigApp: App {
                 .task {
                     let _ = purchaseService.currentTier
                     await purchaseService.refreshEntitlements()
+                    viewModel.refreshMonitoringState()
+                    await viewModel.refreshMonitoringAuthorizationStatus()
+                    viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
                 }
         }
+        .onChange(of: scenePhase) { _, newValue in
+            guard newValue == .active else { return }
+            viewModel.refreshMonitoringState()
+            Task {
+                await viewModel.refreshMonitoringAuthorizationStatus()
+            }
+            viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+        }
     }
 }
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
new file mode 100644
index 0000000..5128c0b
--- /dev/null
+++ b/DomainDig/DomainMonitoringService.swift
@@ -0,0 +1,613 @@
+import Foundation
+import UserNotifications
+
+#if canImport(BackgroundTasks)
+import BackgroundTasks
+#endif
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+enum MonitoringStorage {
+    static let settingsKey = "monitoring.settings"
+    static let logsKey = "monitoring.logs"
+    static let trackedDomainsKey = "trackedDomains"
+    static let historyKey = "lookupHistory"
+    static let maxLogs = 40
+
+    static func loadSettings() -> MonitoringSettings {
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadMonitoringSettings()
+    }
+
+    static func saveSettings(_ settings: MonitoringSettings) {
+        DomainDataPortabilityService.saveMonitoringSettings(settings)
+    }
+
+    static func loadLogs() -> [MonitoringLog] {
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadMonitoringLogs()
+    }
+
+    static func saveLogs(_ logs: [MonitoringLog]) {
+        DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs)))
+    }
+
+    static func loadTrackedDomains() -> [TrackedDomain] {
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadTrackedDomains()
+    }
+
+    static func saveTrackedDomains(_ domains: [TrackedDomain]) {
+        DomainDataPortabilityService.saveTrackedDomains(domains)
+    }
+
+    static func loadHistoryEntries() -> [HistoryEntry] {
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadHistoryEntries()
+    }
+
+    static func saveHistoryEntries(_ entries: [HistoryEntry]) {
+        DomainDataPortabilityService.saveHistoryEntries(entries)
+    }
+
+    static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings {
+        let validIDs = Set(trackedDomains.map(\.id))
+        var sanitized = settings
+        sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) }
+        return sanitized
+    }
+
+    static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
+        let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains)
+        switch sanitized.scope {
+        case .allTracked:
+            return trackedDomains.filter(\.monitoringEnabled)
+        case .selectedOnly:
+            let selected = Set(sanitized.selectedDomainIDs)
+            return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled }
+        }
+    }
+}
+
+struct MonitoringRunOutcome {
+    let success: Bool
+    let message: String
+    let log: MonitoringLog?
+}
+
+@MainActor
+final class DomainMonitoringScheduler {
+    static let shared = DomainMonitoringScheduler()
+    static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh"
+
+    private var isRegistered = false
+
+    private init() {}
+
+    func registerBackgroundTask() {
+        #if canImport(BackgroundTasks)
+        guard !isRegistered else { return }
+        isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
+            guard let refreshTask = task as? BGAppRefreshTask else {
+                task.setTaskCompleted(success: false)
+                return
+            }
+            self.handleAppRefresh(task: refreshTask)
+        }
+        #endif
+    }
+
+    func backgroundRefreshStatusDescription() -> String {
+        #if canImport(UIKit)
+        switch UIApplication.shared.backgroundRefreshStatus {
+        case .available:
+            return "Available"
+        case .denied:
+            return "Disabled in Settings"
+        case .restricted:
+            return "Restricted by the system"
+        @unknown default:
+            return "Unknown"
+        }
+        #else
+        return "Unavailable on this platform"
+        #endif
+    }
+
+    @discardableResult
+    func syncSchedule() -> String? {
+        #if canImport(BackgroundTasks)
+        let settings = MonitoringStorage.loadSettings()
+        guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+            return nil
+        }
+
+        #if canImport(UIKit)
+        guard UIApplication.shared.backgroundRefreshStatus == .available else {
+            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+            return "Background refresh is unavailable."
+        }
+        #endif
+
+        let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
+        request.earliestBeginDate = Date(
+            timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60)
+        )
+
+        do {
+            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+            try BGTaskScheduler.shared.submit(request)
+            return nil
+        } catch {
+            return "Could not schedule monitoring."
+        }
+        #else
+        return "Background monitoring is unavailable on this platform."
+        #endif
+    }
+
+#if canImport(BackgroundTasks)
+    private func handleAppRefresh(task: BGAppRefreshTask) {
+        _ = syncSchedule()
+
+        let worker = Task {
+            let outcome = await DomainMonitoringService.shared.performMonitoring(
+                trigger: .background,
+                requireEnabledSetting: true
+            )
+            task.setTaskCompleted(success: outcome.success)
+        }
+
+        task.expirationHandler = {
+            worker.cancel()
+        }
+    }
+#endif
+}
+
+@MainActor
+final class DomainMonitoringService {
+    static let shared = DomainMonitoringService()
+
+    private let inspectionService = DomainInspectionService()
+    private let maxHistoryEntries = 250
+
+    func performMonitoring(
+        trigger: MonitoringRunTrigger,
+        requireEnabledSetting: Bool
+    ) async -> MonitoringRunOutcome {
+        let trackedDomains = MonitoringStorage.loadTrackedDomains()
+        var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains)
+        MonitoringStorage.saveSettings(settings)
+
+        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            if settings.isEnabled {
+                settings.isEnabled = false
+                MonitoringStorage.saveSettings(settings)
+                await MainActor.run {
+                    _ = DomainMonitoringScheduler.shared.syncSchedule()
+                }
+            }
+            return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil)
+        }
+
+        if requireEnabledSetting, !settings.isEnabled {
+            return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil)
+        }
+
+        var history = MonitoringStorage.loadHistoryEntries()
+        var mutableTrackedDomains = trackedDomains
+        let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
+
+        guard !eligibleDomains.isEmpty else {
+            let log = MonitoringLog(
+                timestamp: Date(),
+                trigger: trigger,
+                domainsChecked: 0,
+                changesFound: 0,
+                alertsTriggered: 0,
+                checkedDomains: [],
+                errors: ["No tracked domains are configured for monitoring."]
+            )
+            saveLog(log)
+            return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log)
+        }
+
+        let notificationsAuthorized: Bool
+        if settings.alertsEnabled {
+            notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
+        } else {
+            notificationsAuthorized = false
+        }
+        var results: [MonitoringDomainResult] = []
+        var errors: [String] = []
+        var alertsTriggered = 0
+
+        for trackedDomain in eligibleDomains {
+            guard !Task.isCancelled else {
+                return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil)
+            }
+
+            let previousSnapshot = latestSnapshot(for: trackedDomain, history: history)
+            let inspectedSnapshot = await inspectionService.inspectSnapshot(
+                domain: trackedDomain.domain,
+                previousSnapshot: previousSnapshot
+            )
+            let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
+            let savedEntry: HistoryEntry?
+            if snapshot.statusMessage == nil {
+                savedEntry = persistSnapshot(
+                    snapshot,
+                    trackedDomainID: trackedDomain.id,
+                    trackedDomains: &mutableTrackedDomains,
+                    history: &history
+                )
+            } else {
+                savedEntry = history.first(where: { $0.id == snapshot.historyEntryID })
+            }
+
+            let alertDescriptor = alertDescriptor(
+                previousSnapshot: previousSnapshot,
+                snapshot: snapshot,
+                entry: savedEntry
+            )
+
+            if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+                mutableTrackedDomains[index].lastMonitoredAt = Date()
+            }
+
+            if notificationsAuthorized,
+               let alertDescriptor,
+               alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
+                await LocalNotificationService.shared.notifyMonitoringAlert(
+                    domain: trackedDomain.domain,
+                    message: alertDescriptor.message,
+                    severity: alertDescriptor.severity
+                )
+                alertsTriggered += 1
+                if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+                    mutableTrackedDomains[index].lastAlertAt = Date()
+                }
+            }
+
+            let result = MonitoringDomainResult(
+                domain: trackedDomain.domain,
+                historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
+                checkedAt: Date(),
+                didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true,
+                summaryMessage: snapshot.statusMessage
+                    ?? savedEntry?.changeSummary?.message
+                    ?? "No meaningful changes",
+                alertSeverity: alertDescriptor?.severity,
+                certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+                resultSource: snapshot.resultSource,
+                errorMessage: snapshot.statusMessage
+            )
+            results.append(result)
+
+            if let errorMessage = result.errorMessage {
+                errors.append("\(trackedDomain.domain): \(errorMessage)")
+            }
+        }
+
+        MonitoringStorage.saveTrackedDomains(mutableTrackedDomains)
+        MonitoringStorage.saveHistoryEntries(history)
+
+        let log = MonitoringLog(
+            timestamp: Date(),
+            trigger: trigger,
+            domainsChecked: results.count,
+            changesFound: results.filter(\.didChange).count,
+            alertsTriggered: alertsTriggered,
+            checkedDomains: results.sorted {
+                $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+            },
+            errors: errors
+        )
+        saveLog(log)
+
+        return MonitoringRunOutcome(
+            success: errors.count < results.count,
+            message: log.summary,
+            log: log
+        )
+    }
+
+    private func saveLog(_ log: MonitoringLog) {
+        var logs = MonitoringStorage.loadLogs()
+        logs.insert(log, at: 0)
+        MonitoringStorage.saveLogs(logs)
+    }
+
+    private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
+        history.first(where: { entry in
+            if let trackedDomainID = entry.trackedDomainID {
+                return trackedDomainID == trackedDomain.id
+            }
+            return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
+        })?.snapshot
+    }
+
+    private func persistSnapshot(
+        _ snapshot: LookupSnapshot,
+        trackedDomainID: UUID,
+        trackedDomains: inout [TrackedDomain],
+        history: inout [HistoryEntry]
+    ) -> HistoryEntry? {
+        let previousSnapshot = latestSnapshot(
+            for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain),
+            history: history
+        )
+        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 entry = HistoryEntry(
+            domain: snapshot.domain,
+            timestamp: snapshot.timestamp,
+            trackedDomainID: trackedDomainID,
+            note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
+            dnsSections: snapshot.dnsSections,
+            sslInfo: snapshot.sslInfo,
+            httpHeaders: snapshot.httpHeaders,
+            reachabilityResults: snapshot.reachabilityResults,
+            ipGeolocation: snapshot.ipGeolocation,
+            emailSecurity: snapshot.emailSecurity,
+            mtaSts: snapshot.emailSecurity?.mtaSts,
+            ownership: snapshot.ownership,
+            ownershipHistory: snapshot.ownershipHistory,
+            ptrRecord: snapshot.ptrRecord,
+            redirectChain: snapshot.redirectChain,
+            subdomains: snapshot.subdomains,
+            extendedSubdomains: snapshot.extendedSubdomains,
+            dnsHistory: snapshot.dnsHistory,
+            domainPricing: snapshot.domainPricing,
+            portScanResults: snapshot.portScanResults,
+            hstsPreloaded: snapshot.hstsPreloaded,
+            availabilityResult: snapshot.availabilityResult,
+            suggestions: snapshot.suggestions,
+            appVersion: snapshot.appVersion,
+            resultSource: snapshot.resultSource,
+            dataSources: snapshot.dataSources,
+            provenanceBySection: snapshot.provenanceBySection,
+            availabilityConfidence: snapshot.availabilityConfidence,
+            ownershipConfidence: snapshot.ownershipConfidence,
+            subdomainConfidence: snapshot.subdomainConfidence,
+            emailSecurityConfidence: snapshot.emailSecurityConfidence,
+            geolocationConfidence: snapshot.geolocationConfidence,
+            errorDetails: snapshot.errorDetails,
+            isPartialSnapshot: snapshot.isPartialSnapshot,
+            validationIssues: snapshot.validationIssues,
+            resolverDisplayName: snapshot.resolverDisplayName,
+            resolverURLString: snapshot.resolverURLString,
+            totalLookupDurationMs: snapshot.totalLookupDurationMs,
+            primaryIP: Self.primaryIPAddress(from: snapshot),
+            finalRedirectURL: snapshot.redirectChain.last?.url,
+            tlsStatusSummary: Self.tlsSummary(from: snapshot),
+            emailSecuritySummary: Self.emailSummary(from: snapshot),
+            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
+            changeSummary: changeSummary,
+            sslError: snapshot.sslError,
+            httpHeadersError: snapshot.httpHeadersError,
+            reachabilityError: snapshot.reachabilityError,
+            ipGeolocationError: snapshot.ipGeolocationError,
+            emailSecurityError: snapshot.emailSecurityError,
+            ownershipError: snapshot.ownershipError,
+            ownershipHistoryError: snapshot.ownershipHistoryError,
+            ptrError: snapshot.ptrError,
+            redirectChainError: snapshot.redirectChainError,
+            subdomainsError: snapshot.subdomainsError,
+            extendedSubdomainsError: snapshot.extendedSubdomainsError,
+            dnsHistoryError: snapshot.dnsHistoryError,
+            domainPricingError: snapshot.domainPricingError,
+            portScanError: snapshot.portScanError
+        )
+
+        history.insert(entry, at: 0)
+        if history.count > maxHistoryEntries {
+            history = Array(history.prefix(maxHistoryEntries))
+        }
+
+        if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
+            trackedDomains[index].lastSnapshotID = entry.id
+            trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
+            trackedDomains[index].updatedAt = snapshot.timestamp
+            trackedDomains[index].lastChangeSummary = changeSummary
+            trackedDomains[index].lastChangeSeverity = changeSummary?.severity
+            trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+            trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
+        }
+
+        return entry
+    }
+
+    private func alertDescriptor(
+        previousSnapshot: LookupSnapshot?,
+        snapshot: LookupSnapshot,
+        entry: HistoryEntry?
+    ) -> (severity: MonitoringAlertSeverity, message: String)? {
+        let changedLabels = Set(
+            (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [])
+                .flatMap(\.items)
+                .filter(\.hasChanges)
+                .map(\.label)
+        )
+
+        if changedLabels.contains("Availability") {
+            return (.critical, "Availability changed")
+        }
+        if changedLabels.contains("Primary IP") {
+            return (.critical, "Primary IP changed")
+        }
+        if changedLabels.contains("Redirect Target") {
+            return (.critical, "Redirect target changed")
+        }
+
+        let ownershipLabels: Set<String> = [
+            "Registrar",
+            "Registration Date",
+            "Expiration Date",
+            "Ownership Status",
+            "Abuse Contact"
+        ]
+        if !changedLabels.isDisjoint(with: ownershipLabels) {
+            return (.warning, "Ownership changed")
+        }
+        if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
+            return (.warning, "DNS changed")
+        }
+
+        let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none
+        let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+        if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel {
+            let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0
+            return (.warning, "Certificate expires in \(daysRemaining) days")
+        }
+
+        if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message {
+            return (.info, message)
+        }
+
+        return nil
+    }
+
+    private static func resolvedSnapshotAfterFallback(
+        _ snapshot: LookupSnapshot,
+        previousSnapshot: LookupSnapshot?
+    ) -> LookupSnapshot {
+        guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
+            return snapshot
+        }
+
+        return LookupSnapshot(
+            historyEntryID: previousSnapshot.historyEntryID,
+            domain: previousSnapshot.domain,
+            timestamp: previousSnapshot.timestamp,
+            trackedDomainID: previousSnapshot.trackedDomainID,
+            note: previousSnapshot.note,
+            appVersion: previousSnapshot.appVersion,
+            resolverDisplayName: previousSnapshot.resolverDisplayName,
+            resolverURLString: previousSnapshot.resolverURLString,
+            dataSources: previousSnapshot.dataSources,
+            provenanceBySection: previousSnapshot.provenanceBySection,
+            availabilityConfidence: previousSnapshot.availabilityConfidence,
+            ownershipConfidence: previousSnapshot.ownershipConfidence,
+            subdomainConfidence: previousSnapshot.subdomainConfidence,
+            emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
+            geolocationConfidence: previousSnapshot.geolocationConfidence,
+            errorDetails: previousSnapshot.errorDetails,
+            isPartialSnapshot: previousSnapshot.isPartialSnapshot,
+            validationIssues: previousSnapshot.validationIssues,
+            totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+            dnsSections: previousSnapshot.dnsSections,
+            dnsError: previousSnapshot.dnsError,
+            availabilityResult: previousSnapshot.availabilityResult,
+            suggestions: previousSnapshot.suggestions,
+            sslInfo: previousSnapshot.sslInfo,
+            sslError: previousSnapshot.sslError,
+            hstsPreloaded: previousSnapshot.hstsPreloaded,
+            httpHeaders: previousSnapshot.httpHeaders,
+            httpSecurityGrade: previousSnapshot.httpSecurityGrade,
+            httpStatusCode: previousSnapshot.httpStatusCode,
+            httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
+            httpProtocol: previousSnapshot.httpProtocol,
+            http3Advertised: previousSnapshot.http3Advertised,
+            httpHeadersError: previousSnapshot.httpHeadersError,
+            reachabilityResults: previousSnapshot.reachabilityResults,
+            reachabilityError: previousSnapshot.reachabilityError,
+            ipGeolocation: previousSnapshot.ipGeolocation,
+            ipGeolocationError: previousSnapshot.ipGeolocationError,
+            emailSecurity: previousSnapshot.emailSecurity,
+            emailSecurityError: previousSnapshot.emailSecurityError,
+            ownership: previousSnapshot.ownership,
+            ownershipError: previousSnapshot.ownershipError,
+            ownershipHistory: previousSnapshot.ownershipHistory,
+            ownershipHistoryError: previousSnapshot.ownershipHistoryError,
+            ptrRecord: previousSnapshot.ptrRecord,
+            ptrError: previousSnapshot.ptrError,
+            redirectChain: previousSnapshot.redirectChain,
+            redirectChainError: previousSnapshot.redirectChainError,
+            subdomains: previousSnapshot.subdomains,
+            subdomainsError: previousSnapshot.subdomainsError,
+            extendedSubdomains: previousSnapshot.extendedSubdomains,
+            extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
+            dnsHistory: previousSnapshot.dnsHistory,
+            dnsHistoryError: previousSnapshot.dnsHistoryError,
+            domainPricing: previousSnapshot.domainPricing,
+            domainPricingError: previousSnapshot.domainPricingError,
+            portScanResults: previousSnapshot.portScanResults,
+            portScanError: previousSnapshot.portScanError,
+            changeSummary: previousSnapshot.changeSummary,
+            resultSource: .snapshot,
+            cachedSections: [],
+            statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
+        )
+    }
+
+    private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
+        let candidateMessages = [
+            snapshot.dnsError,
+            snapshot.httpHeadersError,
+            snapshot.sslError,
+            snapshot.ownershipError,
+            snapshot.subdomainsError,
+            snapshot.redirectChainError,
+            snapshot.ipGeolocationError
+        ]
+        .compactMap { $0?.lowercased() }
+
+        guard !candidateMessages.isEmpty else { return false }
+        let connectivityFailure = candidateMessages.allSatisfy { message in
+            message.hasPrefix("network error:")
+                || message.hasPrefix("timeout:")
+                || message.hasPrefix("rate limit:")
+        }
+
+        let hasMaterialData = !snapshot.dnsSections.isEmpty
+            || !snapshot.httpHeaders.isEmpty
+            || snapshot.sslInfo != nil
+            || snapshot.ownership != nil
+            || !snapshot.subdomains.isEmpty
+
+        return connectivityFailure && !hasMaterialData
+    }
+
+    private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
+        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+    }
+
+    private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
+        guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
+        switch DomainDiffService.certificateWarningLevel(for: snapshot) {
+        case .critical:
+            return "Critical (\(sslInfo.daysUntilExpiry)d)"
+        case .warning:
+            return "Warning (\(sslInfo.daysUntilExpiry)d)"
+        case .none:
+            return "Healthy (\(sslInfo.daysUntilExpiry)d)"
+        }
+    }
+
+    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
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 83d282c..527bf5e 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -1,5 +1,6 @@
 import Foundation
 import SwiftUI
+import UserNotifications
 
 enum ResultTone {
     case primary
@@ -218,10 +219,10 @@ final class DomainViewModel {
 
     private static let recentSearchesKey = "recentSearches"
     private static let maxRecent = 20
-    var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
+    var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches()
 
     private static let savedDomainsKey = "savedDomains"
-    var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
+    var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains()
 
     private static let trackedDomainsKey = "trackedDomains"
     private static let legacyWatchedDomainsKey = "watchedDomains"
@@ -239,6 +240,13 @@ final class DomainViewModel {
     var watchlistSearchText = ""
     var watchlistFilter: WatchlistFilterOption = .all
     var watchlistSortOption: WatchlistSortOption = .pinned
+    var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
+    var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
+    var monitoringRunInProgress = false
+    var monitoringStatusMessage: String?
+    var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined
+    var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
+    var portabilityStatusMessage: String?
     var upgradePrompt: UpgradePromptContext?
     var isPaywallPresented = false
 
@@ -598,12 +606,14 @@ final class DomainViewModel {
         } else {
             savedDomains.append(searchedDomain)
         }
-        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+        DomainDataPortabilityService.saveSavedDomains(savedDomains)
+        refreshDataLifecycleSummary()
     }
 
     func removeSavedDomains(at offsets: IndexSet) {
         savedDomains.remove(atOffsets: offsets)
-        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+        DomainDataPortabilityService.saveSavedDomains(savedDomains)
+        refreshDataLifecycleSummary()
     }
 
     @discardableResult
@@ -636,6 +646,7 @@ final class DomainViewModel {
             at: 0
         )
         persistTrackedDomains()
+        sanitizeMonitoringSelection()
         linkTrackedDomainHistory(for: normalizedDomain)
         return true
     }
@@ -664,6 +675,7 @@ final class DomainViewModel {
         }
         persistTrackedDomains()
         persistHistory()
+        sanitizeMonitoringSelection()
     }
 
     func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
@@ -675,6 +687,7 @@ final class DomainViewModel {
         }
         persistTrackedDomains()
         persistHistory()
+        sanitizeMonitoringSelection()
     }
 
     func togglePinned(for trackedDomain: TrackedDomain) {
@@ -702,6 +715,7 @@ final class DomainViewModel {
     func clearHistory() {
         history.removeAll()
         persistHistory()
+        refreshDataLifecycleSummary()
     }
 
     func clearLookupCache() {
@@ -714,17 +728,157 @@ final class DomainViewModel {
         workflows.removeAll()
         latestWorkflowRunSummary = nil
         persistWorkflows()
+        refreshDataLifecycleSummary()
     }
 
     func clearTrackedDomains() {
         trackedDomains.removeAll()
         refreshingTrackedDomainID = nil
         persistTrackedDomains()
+        sanitizeMonitoringSelection()
+        refreshDataLifecycleSummary()
     }
 
     func clearRecentSearches() {
         recentSearches.removeAll()
-        UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
+        DomainDataPortabilityService.saveRecentSearches([])
+        refreshDataLifecycleSummary()
+    }
+
+    func refreshMonitoringState() {
+        DataMigrationService.migrateIfNeeded()
+        trackedDomains = Self.loadTrackedDomains()
+        history = Self.loadHistoryEntries()
+        monitoringSettings = MonitoringStorage.sanitizeSettings(
+            MonitoringStorage.loadSettings(),
+            trackedDomains: trackedDomains
+        )
+        monitoringLogs = MonitoringStorage.loadLogs()
+        persistMonitoringSettings()
+        refreshDataLifecycleSummary()
+    }
+
+    func refreshDataLifecycleSummary() {
+        dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
+    }
+
+    func refreshPersistedData() {
+        recentSearches = DomainDataPortabilityService.loadRecentSearches()
+        savedDomains = DomainDataPortabilityService.loadSavedDomains()
+        trackedDomains = Self.loadTrackedDomains()
+        history = Self.loadHistoryEntries()
+        workflows = Self.loadWorkflows()
+        monitoringSettings = MonitoringStorage.sanitizeSettings(
+            MonitoringStorage.loadSettings(),
+            trackedDomains: trackedDomains
+        )
+        monitoringLogs = MonitoringStorage.loadLogs()
+        refreshDataLifecycleSummary()
+    }
+
+    func refreshMonitoringAuthorizationStatus() async {
+        let settings = await UNUserNotificationCenter.current().notificationSettings()
+        monitoringNotificationStatus = settings.authorizationStatus
+    }
+
+    func setMonitoringEnabled(_ isEnabled: Bool) {
+        guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            monitoringSettings.isEnabled = false
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
+            return
+        }
+
+        monitoringSettings.isEnabled = isEnabled
+        persistMonitoringSettings()
+        monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+    }
+
+    func setMonitoringScope(_ scope: MonitoringScope) {
+        monitoringSettings.scope = scope
+        persistMonitoringSettings()
+    }
+
+    func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
+        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
+            return
+        }
+        monitoringSettings.frequency = frequency
+        persistMonitoringSettings()
+        monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+    }
+
+    func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
+        guard FeatureAccessService.hasAccess(to: .localAlerts) else {
+            monitoringSettings.alertsEnabled = false
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
+            return
+        }
+        monitoringSettings.alertFilter = filter
+        persistMonitoringSettings()
+    }
+
+    func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
+        guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else {
+            monitoringSettings.alertsEnabled = false
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
+            return
+        }
+        monitoringSettings.alertsEnabled = isEnabled
+        persistMonitoringSettings()
+    }
+
+    func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
+        if isSelected {
+            if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) {
+                monitoringSettings.selectedDomainIDs.append(trackedDomain.id)
+            }
+        } else {
+            monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
+        }
+        persistMonitoringSettings()
+    }
+
+    func toggleMonitoring(for trackedDomain: TrackedDomain) {
+        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
+            return
+        }
+        guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+        trackedDomains[index].monitoringEnabled.toggle()
+        MonitoringStorage.saveTrackedDomains(trackedDomains)
+        sanitizeMonitoringSelection()
+    }
+
+    func requestMonitoringNotificationAuthorization() async {
+        let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
+        monitoringSettings.alertsEnabled = granted
+        persistMonitoringSettings()
+        await refreshMonitoringAuthorizationStatus()
+    }
+
+    func runMonitoringNow() {
+        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
+            return
+        }
+        guard !monitoringRunInProgress else { return }
+
+        monitoringRunInProgress = true
+        monitoringStatusMessage = nil
+
+        Task { [weak self] in
+            guard let self else { return }
+            let outcome = await DomainMonitoringService.shared.performMonitoring(
+                trigger: .manual,
+                requireEnabledSetting: false
+            )
+            await MainActor.run {
+                self.refreshMonitoringState()
+                self.monitoringRunInProgress = false
+                self.monitoringStatusMessage = outcome.message
+            }
+        }
     }
 
     func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
@@ -1159,6 +1313,45 @@ final class DomainViewModel {
         )
     }
 
+    func exportFullBackupData() -> Data? {
+        try? DomainDataPortabilityService.backupData()
+    }
+
+    func exportPortableTrackedDomainsJSONData() -> Data? {
+        try? DomainDataPortabilityService.trackedDomainsExportData()
+    }
+
+    func exportPortableTrackedDomainsCSV() -> String {
+        DomainDataPortabilityService.trackedDomainsCSV()
+    }
+
+    func exportPortableWorkflowsJSONData() -> Data? {
+        try? DomainDataPortabilityService.workflowsExportData()
+    }
+
+    func exportPortableWorkflowsCSV() -> String {
+        DomainDataPortabilityService.workflowsCSV()
+    }
+
+    func exportPortableHistoryJSONData() -> Data? {
+        try? DomainDataPortabilityService.historyExportData()
+    }
+
+    func prepareDataImport(
+        data: Data,
+        fileName: String,
+        mode: DataPortabilityImportMode
+    ) throws -> DataImportPreview {
+        try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
+    }
+
+    func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
+        let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
+        refreshPersistedData()
+        portabilityStatusMessage = result.summary
+        return result
+    }
+
     func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
         let reports = workflowReports(from: summary, changedOnly: changedOnly)
         let base = DomainReportExporter.batchText(
@@ -1806,9 +1999,8 @@ final class DomainViewModel {
     }
 
     private func persistHistory() {
-        if let data = try? JSONEncoder().encode(history) {
-            UserDefaults.standard.set(data, forKey: Self.historyKey)
-        }
+        DomainDataPortabilityService.saveHistoryEntries(history)
+        refreshDataLifecycleSummary()
     }
 
     func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -1818,9 +2010,18 @@ final class DomainViewModel {
     }
 
     private func persistTrackedDomains() {
-        if let data = try? JSONEncoder().encode(trackedDomains) {
-            UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
-        }
+        DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
+        refreshDataLifecycleSummary()
+    }
+
+    private func persistMonitoringSettings() {
+        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
+        MonitoringStorage.saveSettings(monitoringSettings)
+    }
+
+    private func sanitizeMonitoringSelection() {
+        monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
+        MonitoringStorage.saveSettings(monitoringSettings)
     }
 
     private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -1948,7 +2149,8 @@ final class DomainViewModel {
         if recentSearches.count > Self.maxRecent {
             recentSearches = Array(recentSearches.prefix(Self.maxRecent))
         }
-        UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
+        DomainDataPortabilityService.saveRecentSearches(recentSearches)
+        refreshDataLifecycleSummary()
     }
 
     private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
@@ -2510,76 +2712,23 @@ final class DomainViewModel {
     }
 
     private static func loadHistoryEntries() -> [HistoryEntry] {
-        let defaults = UserDefaults.standard
-        guard let data = defaults.data(forKey: historyKey) else {
-            return []
-        }
-
-        if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
-            return entries
-        }
-
-        guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
-            return []
-        }
-
-        let decoder = JSONDecoder()
-        return rawArray.compactMap { item in
-            guard JSONSerialization.isValidJSONObject(item),
-                  let itemData = try? JSONSerialization.data(withJSONObject: item),
-                  let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else {
-                return nil
-            }
-            return entry
-        }
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadHistoryEntries()
     }
 
     private static func loadTrackedDomains() -> [TrackedDomain] {
-        let defaults = UserDefaults.standard
-        let decoder = JSONDecoder()
-
-        if let data = defaults.data(forKey: trackedDomainsKey),
-           let domains = try? decoder.decode([TrackedDomain].self, from: data) {
-            return deduplicatedTrackedDomains(domains)
-        }
-
-        if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
-           let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
-            return deduplicatedTrackedDomains(
-                legacyDomains.map {
-                    TrackedDomain(
-                        id: $0.id,
-                        domain: $0.domain.lowercased(),
-                        createdAt: $0.createdAt,
-                        updatedAt: $0.createdAt,
-                        lastKnownAvailability: $0.lastKnownAvailability
-                    )
-                }
-            )
-        }
-
-        return []
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadTrackedDomains()
     }
 
     private func persistWorkflows() {
-        if let data = try? JSONEncoder().encode(workflows) {
-            UserDefaults.standard.set(data, forKey: Self.workflowsKey)
-        }
+        DomainDataPortabilityService.saveWorkflows(workflows)
+        refreshDataLifecycleSummary()
     }
 
     private static func loadWorkflows() -> [DomainWorkflow] {
-        let defaults = UserDefaults.standard
-        guard let data = defaults.data(forKey: workflowsKey),
-              let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
-            return []
-        }
-
-        return workflows.sorted { lhs, rhs in
-            if lhs.updatedAt != rhs.updatedAt {
-                return lhs.updatedAt > rhs.updatedAt
-            }
-            return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
-        }
+        DataMigrationService.migrateIfNeeded()
+        return DomainDataPortabilityService.loadWorkflows()
     }
 
     private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
index 535ba7c..9fae3bd 100644
--- a/DomainDig/FeatureAccessService.swift
+++ b/DomainDig/FeatureAccessService.swift
@@ -25,6 +25,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
     case limitedTracking
     case workflows
     case batchOperations
+    case automatedMonitoring
+    case localAlerts
     case advancedExports
     case ownershipHistory
     case dnsHistory
@@ -45,6 +47,10 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
             return "Workflows"
         case .batchOperations:
             return "Batch operations"
+        case .automatedMonitoring:
+            return "Background monitoring"
+        case .localAlerts:
+            return "Local alerts"
         case .advancedExports:
             return "Advanced exports"
         case .ownershipHistory:
@@ -94,7 +100,16 @@ enum FeatureAccessService {
         case .pro:
             return FeatureEntitlements(
                 tier: .pro,
-                capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
+                capabilities: [
+                    .singleLookup,
+                    .basicHistory,
+                    .limitedTracking,
+                    .workflows,
+                    .batchOperations,
+                    .automatedMonitoring,
+                    .localAlerts,
+                    .advancedExports
+                ],
                 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
                 workflowLimit: nil,
                 batchSizeLimit: nil
@@ -140,7 +155,7 @@ enum FeatureAccessService {
 
     static func upgradeMessage(for capability: FeatureCapability) -> String {
         switch capability {
-        case .workflows, .batchOperations, .advancedExports:
+        case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
             return "Available in Pro"
         case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
             return "Available in Data+"
diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist
index e397175..a24a71e 100644
--- a/DomainDig/Info.plist
+++ b/DomainDig/Info.plist
@@ -6,5 +6,13 @@
 	<true/>
 	<key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key>
 	<dict/>
+	<key>BGTaskSchedulerPermittedIdentifiers</key>
+	<array>
+		<string>net.cleberg.DomainDig.monitor.refresh</string>
+	</array>
+	<key>UIBackgroundModes</key>
+	<array>
+		<string>fetch</string>
+	</array>
 </dict>
 </plist>
diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
index 450d844..7b43d67 100644
--- a/DomainDig/LocalNotificationService.swift
+++ b/DomainDig/LocalNotificationService.swift
@@ -27,6 +27,18 @@ final class LocalNotificationService {
         }
     }
 
+    func isAuthorizedForAlerts() async -> Bool {
+        let settings = await UNUserNotificationCenter.current().notificationSettings()
+        switch settings.authorizationStatus {
+        case .authorized, .provisional, .ephemeral:
+            return true
+        case .denied, .notDetermined:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+
     func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
         await schedule(
             identifier: "domain-change-\(domain)",
@@ -45,6 +57,27 @@ final class LocalNotificationService {
         )
     }
 
+    func notifyMonitoringAlert(
+        domain: String,
+        message: String,
+        severity: MonitoringAlertSeverity
+    ) async {
+        let interruptionLevel: UNNotificationInterruptionLevel
+        switch severity {
+        case .critical:
+            interruptionLevel = .timeSensitive
+        case .warning, .info:
+            interruptionLevel = .active
+        }
+
+        await schedule(
+            identifier: "monitoring-\(domain)-\(UUID().uuidString)",
+            title: domain,
+            body: message,
+            interruptionLevel: interruptionLevel
+        )
+    }
+
     func notifySweepComplete(summary: BatchSweepSummary) async {
         let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
         await schedule(
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 530a89e..7715b4a 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -591,12 +591,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
     var updatedAt: Date
     var note: String?
     var isPinned: Bool
+    var monitoringEnabled: Bool
     var lastKnownAvailability: DomainAvailabilityStatus?
     var lastSnapshotID: UUID?
     var lastChangeSummary: DomainChangeSummary?
     var lastChangeSeverity: ChangeSeverity?
     var certificateWarningLevel: CertificateWarningLevel
     var certificateDaysRemaining: Int?
+    var lastMonitoredAt: Date?
+    var lastAlertAt: Date?
 
     init(
         id: UUID = UUID(),
@@ -605,12 +608,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         updatedAt: Date = Date(),
         note: String? = nil,
         isPinned: Bool = false,
+        monitoringEnabled: Bool = true,
         lastKnownAvailability: DomainAvailabilityStatus? = nil,
         lastSnapshotID: UUID? = nil,
         lastChangeSummary: DomainChangeSummary? = nil,
         lastChangeSeverity: ChangeSeverity? = nil,
         certificateWarningLevel: CertificateWarningLevel = .none,
-        certificateDaysRemaining: Int? = nil
+        certificateDaysRemaining: Int? = nil,
+        lastMonitoredAt: Date? = nil,
+        lastAlertAt: Date? = nil
     ) {
         self.id = id
         self.domain = domain
@@ -618,12 +624,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         self.updatedAt = updatedAt
         self.note = note
         self.isPinned = isPinned
+        self.monitoringEnabled = monitoringEnabled
         self.lastKnownAvailability = lastKnownAvailability
         self.lastSnapshotID = lastSnapshotID
         self.lastChangeSummary = lastChangeSummary
         self.lastChangeSeverity = lastChangeSeverity
         self.certificateWarningLevel = certificateWarningLevel
         self.certificateDaysRemaining = certificateDaysRemaining
+        self.lastMonitoredAt = lastMonitoredAt
+        self.lastAlertAt = lastAlertAt
     }
 
     init(from decoder: Decoder) throws {
@@ -634,12 +643,224 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
         note = try container.decodeIfPresent(String.self, forKey: .note)
         isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
+        monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
         lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
         lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
         lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
         lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
         certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
         certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
+        lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
+        lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
+    }
+}
+
+enum MonitoringFrequency: String, Codable, CaseIterable, Identifiable {
+    case daily
+    case twiceDaily
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .daily:
+            return "Daily"
+        case .twiceDaily:
+            return "Twice Daily"
+        }
+    }
+
+    var schedulingInterval: TimeInterval {
+        switch self {
+        case .daily:
+            return 24 * 60 * 60
+        case .twiceDaily:
+            return 12 * 60 * 60
+        }
+    }
+}
+
+enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
+    case allTracked
+    case selectedOnly
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .allTracked:
+            return "All Tracked"
+        case .selectedOnly:
+            return "Selected Only"
+        }
+    }
+}
+
+enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable {
+    case info
+    case warning
+    case critical
+
+    static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool {
+        lhs.rawValue < rhs.rawValue
+    }
+
+    var title: String {
+        switch self {
+        case .info:
+            return "Info"
+        case .warning:
+            return "Warning"
+        case .critical:
+            return "Critical"
+        }
+    }
+}
+
+enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable {
+    case criticalOnly
+    case criticalAndWarnings
+    case allChanges
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .criticalOnly:
+            return "Critical Only"
+        case .criticalAndWarnings:
+            return "Critical + Warnings"
+        case .allChanges:
+            return "All Changes"
+        }
+    }
+
+    var minimumSeverity: MonitoringAlertSeverity {
+        switch self {
+        case .criticalOnly:
+            return .critical
+        case .criticalAndWarnings:
+            return .warning
+        case .allChanges:
+            return .info
+        }
+    }
+}
+
+enum MonitoringRunTrigger: String, Codable {
+    case manual
+    case background
+    case cli
+
+    var title: String {
+        switch self {
+        case .manual:
+            return "Manual"
+        case .background:
+            return "Background"
+        case .cli:
+            return "CLI"
+        }
+    }
+}
+
+struct MonitoringSettings: Codable, Equatable {
+    var isEnabled: Bool
+    var frequency: MonitoringFrequency
+    var scope: MonitoringScope
+    var selectedDomainIDs: [UUID]
+    var alertFilter: MonitoringAlertFilter
+    var alertsEnabled: Bool
+
+    init(
+        isEnabled: Bool = false,
+        frequency: MonitoringFrequency = .daily,
+        scope: MonitoringScope = .allTracked,
+        selectedDomainIDs: [UUID] = [],
+        alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
+        alertsEnabled: Bool = false
+    ) {
+        self.isEnabled = isEnabled
+        self.frequency = frequency
+        self.scope = scope
+        self.selectedDomainIDs = selectedDomainIDs
+        self.alertFilter = alertFilter
+        self.alertsEnabled = alertsEnabled
+    }
+}
+
+struct MonitoringDomainResult: Codable, Identifiable, Equatable {
+    let id: UUID
+    let domain: String
+    let historyEntryID: UUID?
+    let checkedAt: Date
+    let didChange: Bool
+    let summaryMessage: String
+    let alertSeverity: MonitoringAlertSeverity?
+    let certificateWarningLevel: CertificateWarningLevel
+    let resultSource: LookupResultSource
+    let errorMessage: String?
+
+    init(
+        id: UUID = UUID(),
+        domain: String,
+        historyEntryID: UUID?,
+        checkedAt: Date,
+        didChange: Bool,
+        summaryMessage: String,
+        alertSeverity: MonitoringAlertSeverity?,
+        certificateWarningLevel: CertificateWarningLevel,
+        resultSource: LookupResultSource,
+        errorMessage: String? = nil
+    ) {
+        self.id = id
+        self.domain = domain
+        self.historyEntryID = historyEntryID
+        self.checkedAt = checkedAt
+        self.didChange = didChange
+        self.summaryMessage = summaryMessage
+        self.alertSeverity = alertSeverity
+        self.certificateWarningLevel = certificateWarningLevel
+        self.resultSource = resultSource
+        self.errorMessage = errorMessage
+    }
+}
+
+struct MonitoringLog: Codable, Identifiable, Equatable {
+    let id: UUID
+    let timestamp: Date
+    let trigger: MonitoringRunTrigger
+    let domainsChecked: Int
+    let changesFound: Int
+    let alertsTriggered: Int
+    let checkedDomains: [MonitoringDomainResult]
+    let errors: [String]
+
+    init(
+        id: UUID = UUID(),
+        timestamp: Date,
+        trigger: MonitoringRunTrigger,
+        domainsChecked: Int,
+        changesFound: Int,
+        alertsTriggered: Int,
+        checkedDomains: [MonitoringDomainResult],
+        errors: [String] = []
+    ) {
+        self.id = id
+        self.timestamp = timestamp
+        self.trigger = trigger
+        self.domainsChecked = domainsChecked
+        self.changesFound = changesFound
+        self.alertsTriggered = alertsTriggered
+        self.checkedDomains = checkedDomains
+        self.errors = errors
+    }
+
+    var summary: String {
+        if errors.isEmpty {
+            return "\(changesFound) changes across \(domainsChecked) domains"
+        }
+        return "\(changesFound) changes, \(errors.count) errors"
     }
 }
 
diff --git a/DomainDig/MonitoringView.swift b/DomainDig/MonitoringView.swift
new file mode 100644
index 0000000..0b7a916
--- /dev/null
+++ b/DomainDig/MonitoringView.swift
@@ -0,0 +1,198 @@
+import SwiftUI
+
+struct MonitoringView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+
+    private var monitoredDomainsCount: Int {
+        MonitoringStorage.monitoredDomains(
+            settings: viewModel.monitoringSettings,
+            trackedDomains: viewModel.trackedDomains
+        ).count
+    }
+
+    var body: some View {
+        List {
+            Section("Overview") {
+                VStack(alignment: .leading, spacing: 8) {
+                    LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only")
+                    LabeledContent("Domains", value: "\(monitoredDomainsCount)")
+                    LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title)
+                    LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off")
+
+                    if let monitoringStatusMessage = viewModel.monitoringStatusMessage,
+                       !monitoringStatusMessage.isEmpty {
+                        Text(monitoringStatusMessage)
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
+                    }
+
+                    Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") {
+                        viewModel.runMonitoringNow()
+                    }
+                    .disabled(viewModel.monitoringRunInProgress)
+                }
+                .padding(.vertical, 4)
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+            if viewModel.monitoringLogs.isEmpty {
+                Section {
+                    EmptyStateCardView(
+                        title: "No Monitoring Runs Yet",
+                        message: "Monitoring history appears here after manual or background runs finish.",
+                        suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.",
+                        systemImage: "waveform.path.ecg"
+                    )
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            } else {
+                Section("Recent Runs") {
+                    ForEach(viewModel.monitoringLogs) { log in
+                        NavigationLink {
+                            MonitoringLogDetailView(viewModel: viewModel, log: log)
+                        } label: {
+                            VStack(alignment: .leading, spacing: 6) {
+                                HStack {
+                                    Text(log.trigger.title)
+                                        .font(appDensity.font(.callout))
+                                        .foregroundStyle(.primary)
+                                    Spacer()
+                                    Text(log.timestamp.formatted(date: .abbreviated, time: .shortened))
+                                        .font(appDensity.font(.caption2))
+                                        .foregroundStyle(.secondary)
+                                }
+
+                                Text(log.summary)
+                                    .font(appDensity.font(.caption))
+                                    .foregroundStyle(.secondary)
+
+                                HStack(spacing: 8) {
+                                    metricBadge(title: "\(log.domainsChecked) checked")
+                                    if log.changesFound > 0 {
+                                        metricBadge(title: "\(log.changesFound) changed", tint: .orange)
+                                    }
+                                    if log.alertsTriggered > 0 {
+                                        metricBadge(title: "\(log.alertsTriggered) alerts", tint: .red)
+                                    }
+                                }
+                            }
+                            .padding(.vertical, 4)
+                        }
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("Monitoring")
+        .preferredColorScheme(.dark)
+        .onAppear {
+            viewModel.refreshMonitoringState()
+        }
+    }
+
+    private func metricBadge(title: String, tint: Color = .cyan) -> some View {
+        Text(title)
+            .font(appDensity.font(.caption2))
+            .foregroundStyle(tint)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(tint.opacity(0.16))
+            .clipShape(Capsule())
+    }
+}
+
+struct MonitoringLogDetailView: View {
+    @Environment(\.appDensity) private var appDensity
+    @Bindable var viewModel: DomainViewModel
+    let log: MonitoringLog
+
+    var body: some View {
+        List {
+            Section("Summary") {
+                LabeledContent("Trigger", value: log.trigger.title)
+                LabeledContent("Checked", value: "\(log.domainsChecked)")
+                LabeledContent("Changes", value: "\(log.changesFound)")
+                LabeledContent("Alerts", value: "\(log.alertsTriggered)")
+                LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened))
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+            Section("Domains") {
+                ForEach(log.checkedDomains) { result in
+                    VStack(alignment: .leading, spacing: 6) {
+                        HStack {
+                            Text(result.domain)
+                                .font(appDensity.font(.callout))
+                                .foregroundStyle(.primary)
+                            Spacer()
+                            if let alertSeverity = result.alertSeverity {
+                                Text(alertSeverity.title.uppercased())
+                                    .font(appDensity.font(.caption2))
+                                    .foregroundStyle(color(for: alertSeverity))
+                            }
+                        }
+
+                        Text(result.summaryMessage)
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
+
+                        HStack(spacing: 10) {
+                            Text(result.resultSource.label)
+                            Text(result.didChange ? "Changed" : "No change")
+                            if result.certificateWarningLevel != .none {
+                                Text(result.certificateWarningLevel.title)
+                            }
+                        }
+                        .font(appDensity.font(.caption2))
+                        .foregroundStyle(.secondary)
+
+                        if let errorMessage = result.errorMessage {
+                            Text(errorMessage)
+                                .font(appDensity.font(.caption2))
+                                .foregroundStyle(.yellow)
+                        }
+
+                        if let historyEntryID = result.historyEntryID,
+                           let entry = viewModel.history.first(where: { $0.id == historyEntryID }) {
+                            NavigationLink("Open Snapshot") {
+                                HistoryDetailView(viewModel: viewModel, entry: entry)
+                            }
+                            .font(appDensity.font(.caption))
+                        }
+                    }
+                    .padding(.vertical, 4)
+                }
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+            if !log.errors.isEmpty {
+                Section("Errors") {
+                    ForEach(log.errors, id: \.self) { error in
+                        Text(error)
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
+                    }
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("Run Details")
+        .preferredColorScheme(.dark)
+    }
+
+    private func color(for severity: MonitoringAlertSeverity) -> Color {
+        switch severity {
+        case .info:
+            return .secondary
+        case .warning:
+            return .yellow
+        case .critical:
+            return .red
+        }
+    }
+}
diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift
index d0293a8..90578c7 100644
--- a/DomainDig/PaywallView.swift
+++ b/DomainDig/PaywallView.swift
@@ -10,7 +10,7 @@ struct PaywallView: View {
         NavigationStack {
             List {
                 Section {
-                    Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
+                    Text("Pro unlocks workflows, scale, monitoring automation, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
                         .font(appDensity.font(.body, design: .default))
                         .foregroundStyle(.secondary)
                         .fixedSize(horizontal: false, vertical: true)
@@ -20,6 +20,8 @@ struct PaywallView: View {
                     featureRow("Unlimited tracked domains")
                     featureRow("Workflows")
                     featureRow("Larger batch sizes")
+                    featureRow("Background monitoring")
+                    featureRow("Local alerts")
                     featureRow("Advanced exports")
                 }
 
diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift
index 8de2626..35c172f 100644
--- a/DomainDig/PremiumAccessService.swift
+++ b/DomainDig/PremiumAccessService.swift
@@ -9,8 +9,10 @@ enum PremiumAccessService {
             return FeatureAccessService.hasAccess(to: .batchOperations)
         case .unlimitedTrackedDomains:
             return FeatureAccessService.currentTier != .free
-        case .automatedMonitoring, .pushAlerts:
-            return false
+        case .automatedMonitoring:
+            return FeatureAccessService.hasAccess(to: .automatedMonitoring)
+        case .pushAlerts:
+            return FeatureAccessService.hasAccess(to: .localAlerts)
         }
     }
 
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 2d2b8ed..adf5702 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -20,6 +20,13 @@ struct RootTabView: View {
                 Label("Watchlist", systemImage: "eye")
             }
 
+            NavigationStack {
+                MonitoringView(viewModel: viewModel)
+            }
+            .tabItem {
+                Label("Monitoring", systemImage: "waveform.path.ecg")
+            }
+
             NavigationStack {
                 HistoryView(viewModel: viewModel)
             }
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index b4e5011..968914f 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -287,6 +287,18 @@ struct WatchlistRowView: View {
                 .font(appDensity.font(.caption2))
                 .foregroundStyle(.secondary)
 
+            HStack(spacing: 8) {
+                Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
+                if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
+                    Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
+                }
+                if let lastAlertAt = trackedDomain.lastAlertAt {
+                    Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
+                }
+            }
+            .font(appDensity.font(.caption2))
+            .foregroundStyle(.secondary)
+
             indicatorRow
 
             if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
@@ -399,6 +411,15 @@ struct TrackedDomainDetailView: View {
                     Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
                 }
 
+                Button {
+                    viewModel.toggleMonitoring(for: liveTrackedDomain)
+                } label: {
+                    Label(
+                        liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
+                        systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
+                    )
+                }
+
                 Button {
                     noteDraft = liveTrackedDomain.note ?? ""
                     isEditingNote = true
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index a6d2cf3..8c433d8 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -4,14 +4,24 @@ import Foundation
 struct DomainDigCLI {
     static func main() async {
         let arguments = Array(CommandLine.arguments.dropFirst())
+        let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
 
         guard let command = CommandLine.arguments.first else {
-            fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
+            fputs(usageText, stderr)
             Foundation.exit(1)
         }
         _ = command
 
-        let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
+        if arguments.first == "backup" {
+            runBackupCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
+            return
+        }
+
+        if arguments.first == "monitor" {
+            await runMonitorCommand(wantsJSON: wantsJSON)
+            return
+        }
+
         let wantsOwnershipHistory = arguments.contains("--ownership-history")
         let wantsDNSHistory = arguments.contains("--dns-history")
         let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
@@ -24,7 +34,7 @@ struct DomainDigCLI {
             .filter { !$0.isEmpty }
 
         guard !requestedDomains.isEmpty else {
-            fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
+            fputs(usageText, stderr)
             Foundation.exit(1)
         }
 
@@ -252,10 +262,172 @@ struct DomainDigCLI {
     }
 
     private static func loadHistoryEntries() -> [HistoryEntry] {
-        guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
-              let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
-            return []
+        DomainDataPortabilityService.loadHistoryEntries()
+    }
+
+    private static func runBackupCommand(arguments: [String], wantsJSON: Bool) {
+        guard let subcommand = arguments.first else {
+            fputs(usageText, stderr)
+            Foundation.exit(1)
+        }
+
+        switch subcommand {
+        case "export":
+            do {
+                let data = try DomainDataPortabilityService.backupData()
+                let outputPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") })
+                if let outputPath {
+                    try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic)
+                } else {
+                    FileHandle.standardOutput.write(data)
+                    if data.last != 0x0A {
+                        FileHandle.standardOutput.write(Data([0x0A]))
+                    }
+                }
+            } catch {
+                fputs("domaindig backup export: \(error.localizedDescription)\n", stderr)
+                Foundation.exit(1)
+            }
+        case "validate":
+            guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
+                fputs("usage: domaindig backup validate <path>\n", stderr)
+                Foundation.exit(1)
+            }
+
+            do {
+                let data = try Data(contentsOf: URL(fileURLWithPath: path))
+                let report = try DomainDataPortabilityService.validateBackup(data: data, fileName: URL(fileURLWithPath: path).lastPathComponent)
+                if wantsJSON {
+                    let payload = [
+                        "warnings": report.warnings,
+                        "errors": report.errors
+                    ]
+                    let encoded = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
+                    FileHandle.standardOutput.write(encoded)
+                } else {
+                    let lines = [
+                        "Warnings: \(report.warnings.count)",
+                        "Errors: \(report.errors.count)"
+                    ] + report.warnings.map { "warning: \($0)" } + report.errors.map { "error: \($0)" }
+                    FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
+                }
+                FileHandle.standardOutput.write(Data([0x0A]))
+                if !report.errors.isEmpty {
+                    Foundation.exit(1)
+                }
+            } catch {
+                fputs("domaindig backup validate: \(error.localizedDescription)\n", stderr)
+                Foundation.exit(1)
+            }
+        case "import":
+            guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
+                fputs("usage: domaindig backup import <path> [--replace]\n", stderr)
+                Foundation.exit(1)
+            }
+
+            let mode: DataPortabilityImportMode = arguments.contains("--replace") ? .replace : .merge
+
+            do {
+                let fileURL = URL(fileURLWithPath: path)
+                let data = try Data(contentsOf: fileURL)
+                let preview = try DomainDataPortabilityService.prepareImport(
+                    data: data,
+                    fileName: fileURL.lastPathComponent,
+                    mode: mode
+                )
+                guard preview.kind == .backup else {
+                    fputs("domaindig backup import: expected a full backup file\n", stderr)
+                    Foundation.exit(1)
+                }
+                let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
+                let lines = [result.summary] + result.warnings.map { "warning: \($0)" }
+                FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
+                FileHandle.standardOutput.write(Data([0x0A]))
+            } catch {
+                fputs("domaindig backup import: \(error.localizedDescription)\n", stderr)
+                Foundation.exit(1)
+            }
+        default:
+            fputs(usageText, stderr)
+            Foundation.exit(1)
+        }
+    }
+
+    private static func runMonitorCommand(wantsJSON: Bool) async {
+        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+            fputs("domaindig monitor: monitoring requires Pro\n", stderr)
+            Foundation.exit(1)
+        }
+
+        let outcome = await DomainMonitoringService.shared.performMonitoring(
+            trigger: .cli,
+            requireEnabledSetting: false
+        )
+
+        guard let log = outcome.log else {
+            fputs("domaindig monitor: \(outcome.message)\n", stderr)
+            Foundation.exit(1)
+        }
+
+        do {
+            let output: Data
+            if wantsJSON {
+                let encoder = JSONEncoder()
+                encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+                output = try encoder.encode(log)
+            } else {
+                output = Data(monitoringTextSummary(for: log).utf8)
+            }
+            FileHandle.standardOutput.write(output)
+            if output.last != 0x0A {
+                FileHandle.standardOutput.write(Data([0x0A]))
+            }
+            if !outcome.success {
+                Foundation.exit(1)
+            }
+        } catch {
+            fputs("domaindig monitor: \(error.localizedDescription)\n", stderr)
+            Foundation.exit(1)
+        }
+    }
+
+    private static func monitoringTextSummary(for log: MonitoringLog) -> String {
+        var lines = [
+            "DomainDig Monitoring",
+            "====================",
+            "Trigger: \(log.trigger.title)",
+            "Timestamp: \(log.timestamp.formatted(date: .abbreviated, time: .shortened))",
+            "Checked: \(log.domainsChecked)",
+            "Changes: \(log.changesFound)",
+            "Alerts: \(log.alertsTriggered)",
+            ""
+        ]
+
+        if log.checkedDomains.isEmpty {
+            lines.append("No domains were checked.")
+        } else {
+            for result in log.checkedDomains {
+                let severity = result.alertSeverity?.title ?? "None"
+                lines.append("\(result.domain): \(result.summaryMessage) [alert: \(severity)]")
+            }
+        }
+
+        if !log.errors.isEmpty {
+            lines.append("")
+            lines.append("Errors:")
+            lines.append(contentsOf: log.errors.map { "- \($0)" })
         }
-        return entries
+
+        return lines.joined(separator: "\n")
+    }
+
+    private static var usageText: String {
+        """
+        usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]
+               domaindig monitor [--json]
+               domaindig backup export [path]
+               domaindig backup import <path> [--replace]
+               domaindig backup validate <path> [--json]
+        """
     }
 }