krz/domain-dig

an ios app for DNS & SSL analysis

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

b35f1b432fc24fbab1fa05593c9a0bca7e4d95a6

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-24T04:12:34Z

feat(v3.6.0): add iCloud sharing for workflows and tracked domains

- implement CloudKit sharing (CKShare) for TrackedDomain and DomainWorkflow
- support read-only and editable permissions
- add Share actions and shared state indicators in UI
- handle shared record ownership and participant roles
- implement deterministic conflict resolution to prevent duplication
- ensure compatibility with existing iCloud sync layer
- maintain local-first behavior with graceful offline handling
- remove debug logging for domain availability/rdap messages

notes:
- no custom backend or accounts introduced
- sharing is Apple ecosystem only (iCloud-based)
- large history data remains local-only
 DomainDig.xcodeproj/project.pbxproj                |   15 +-
 .../xcschemes/xcschememanagement.plist             |    4 +-
 DomainDig/AppVersion.swift                         |    2 +-
 DomainDig/CloudSharingLifecycle.swift              |   31 +
 DomainDig/CloudSharingSheet.swift                  |   84 ++
 DomainDig/CloudSyncService.swift                   | 1588 ++++++++++++++++++++
 DomainDig/ContentView.swift                        |   59 +-
 DomainDig/DomainAvailabilityService.swift          |   20 -
 DomainDig/DomainDig.entitlements                   |   14 +
 DomainDig/DomainDigApp.swift                       |   11 +
 DomainDig/DomainViewModel.swift                    |  102 +-
 DomainDig/Info.plist                               |    2 +
 DomainDig/Models.swift                             |   75 +-
 DomainDig/WatchlistView.swift                      |   56 +-
 DomainDig/WorkflowsView.swift                      |   31 +
 DomainDigCLI.swift                                 |    2 +
 16 files changed, 2048 insertions(+), 48 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 22c4dfc..1ccc6bc 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -169,6 +169,11 @@
 				TargetAttributes = {
 					8B7800682F6090E300933221 = {
 						CreatedOnToolsVersion = 26.3;
+						SystemCapabilities = {
+							com.apple.iCloud = {
+								enabled = 1;
+							};
+						};
 					};
 				};
 			};
@@ -359,8 +364,9 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 26;
+				CURRENT_PROJECT_VERSION = 28;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -377,7 +383,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.4.0;
+				MARKETING_VERSION = 3.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -395,8 +401,9 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 26;
+				CURRENT_PROJECT_VERSION = 28;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -413,7 +420,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.4.0;
+				MARKETING_VERSION = 3.6.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index 06d0111..9e08675 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,12 +7,12 @@
 		<key>DomainDig.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>1</integer>
+			<integer>0</integer>
 		</dict>
 		<key>DomainDigCLI.xcscheme_^#shared#^_</key>
 		<dict>
 			<key>orderHint</key>
-			<integer>0</integer>
+			<integer>1</integer>
 		</dict>
 	</dict>
 </dict>
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index 346beb4..cc675b0 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     nonisolated static var current: String {
-        "3.4.0"
+        "3.5.0"
     }
 }
diff --git a/DomainDig/CloudSharingLifecycle.swift b/DomainDig/CloudSharingLifecycle.swift
new file mode 100644
index 0000000..fc5de80
--- /dev/null
+++ b/DomainDig/CloudSharingLifecycle.swift
@@ -0,0 +1,31 @@
+import CloudKit
+import UIKit
+
+final class DomainDigAppDelegate: NSObject, UIApplicationDelegate {
+    func application(
+        _ application: UIApplication,
+        configurationForConnecting connectingSceneSession: UISceneSession,
+        options: UIScene.ConnectionOptions
+    ) -> UISceneConfiguration {
+        let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
+        configuration.delegateClass = DomainDigSceneDelegate.self
+        return configuration
+    }
+}
+
+final class DomainDigSceneDelegate: NSObject, UIWindowSceneDelegate {
+    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+        guard let metadata = connectionOptions.cloudKitShareMetadata else { return }
+        accept(metadata: metadata)
+    }
+
+    func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
+        accept(metadata: cloudKitShareMetadata)
+    }
+
+    private func accept(metadata: CKShare.Metadata) {
+        Task {
+            try? await CloudSyncService.shared.acceptShare(metadata: metadata)
+        }
+    }
+}
diff --git a/DomainDig/CloudSharingSheet.swift b/DomainDig/CloudSharingSheet.swift
new file mode 100644
index 0000000..3d524ef
--- /dev/null
+++ b/DomainDig/CloudSharingSheet.swift
@@ -0,0 +1,84 @@
+import CloudKit
+import Foundation
+import SwiftUI
+import UIKit
+
+struct CloudSharingSheet: UIViewControllerRepresentable {
+    @Environment(\.dismiss) private var dismiss
+
+    let entity: ShareableEntity
+    let title: String
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(dismiss: dismiss, title: title)
+    }
+
+    func makeUIViewController(context: Context) -> UIViewController {
+        UIViewController()
+    }
+
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+        context.coordinator.presentIfNeeded(from: uiViewController, entity: entity)
+    }
+
+    final class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
+        private let dismissAction: DismissAction
+        private let title: String
+        private let observer = CKSystemSharingUIObserver(container: .default())
+        private var hasPresented = false
+
+        init(dismiss: DismissAction, title: String) {
+            self.dismissAction = dismiss
+            self.title = title
+            super.init()
+
+            observer.systemSharingUIDidSaveShareBlock = { _, _ in
+                Task { @MainActor in
+                    await CloudSyncService.shared.syncNow(trigger: .manual)
+                }
+            }
+
+            observer.systemSharingUIDidStopSharingBlock = { _, _ in
+                Task { @MainActor in
+                    await CloudSyncService.shared.syncNow(trigger: .manual)
+                }
+            }
+        }
+
+        func presentIfNeeded(from presenter: UIViewController, entity: ShareableEntity) {
+            guard !hasPresented else { return }
+            hasPresented = true
+
+            let itemProvider = NSItemProvider()
+            let container = CKContainer.default()
+
+            Task { @MainActor in
+                do {
+                    if let existingShare = try await CloudSyncService.shared.existingShare(for: entity) {
+                        itemProvider.registerCKShare(existingShare, container: container)
+                    } else {
+                        itemProvider.registerCKShare(container: container) {
+                            try await CloudSyncService.shared.createShare(for: entity)
+                        }
+                    }
+                } catch {
+                    dismissAction()
+                    return
+                }
+
+                let configuration = UIActivityItemsConfiguration(itemProviders: [itemProvider])
+                let activityController = UIActivityViewController(activityItemsConfiguration: configuration)
+                activityController.completionWithItemsHandler = { [dismissAction] _, _, _, _ in
+                    dismissAction()
+                }
+                activityController.popoverPresentationController?.sourceView = presenter.view
+                activityController.presentationController?.delegate = self
+                presenter.present(activityController, animated: true)
+            }
+        }
+
+        func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+            dismissAction()
+        }
+    }
+}
diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift
new file mode 100644
index 0000000..9947e08
--- /dev/null
+++ b/DomainDig/CloudSyncService.swift
@@ -0,0 +1,1588 @@
+import CloudKit
+import Foundation
+import Network
+import Observation
+
+extension Notification.Name {
+    static let cloudSyncDidApplyChanges = Notification.Name("CloudSyncService.didApplyChanges")
+}
+
+enum CloudSyncStatus: String, Codable {
+    case disabled
+    case synced
+    case syncing
+    case offline
+    case iCloudUnavailable
+    case conflictResolved
+    case error
+
+    var title: String {
+        switch self {
+        case .disabled:
+            return "Off"
+        case .synced:
+            return "Synced"
+        case .syncing:
+            return "Syncing"
+        case .offline:
+            return "Offline"
+        case .iCloudUnavailable:
+            return "iCloud unavailable"
+        case .conflictResolved:
+            return "Conflict resolved"
+        case .error:
+            return "Error"
+        }
+    }
+}
+
+enum CloudSyncTrigger: String {
+    case launch
+    case automatic
+    case manual
+    case `import`
+}
+
+enum ShareableEntity: Identifiable, Hashable {
+    case trackedDomain(String)
+    case workflow(UUID)
+
+    var id: String {
+        switch self {
+        case .trackedDomain(let domain):
+            return "tracked:\(domain)"
+        case .workflow(let identifier):
+            return "workflow:\(identifier.uuidString)"
+        }
+    }
+}
+
+private enum CloudRecordType {
+    static let trackedDomain = "TrackedDomain"
+    static let workflow = "DomainWorkflow"
+    static let appSettings = "AppSettingsSnapshot"
+    static let monitoringSettings = "MonitoringSettings"
+    static let domainNote = "DomainNote"
+    static let historyMetadata = "HistoryMetadata"
+    static let tombstone = "SyncTombstone"
+}
+
+private enum CloudRecordKey {
+    static let payload = "payload"
+    static let updatedAt = "updatedAt"
+    static let domain = "domain"
+    static let identifier = "identifier"
+    static let entityType = "entityType"
+    static let deletedAt = "deletedAt"
+}
+
+private enum SyncEntityType: String, Codable {
+    case trackedDomain
+    case workflow
+}
+
+private struct SyncedAppSettings: Codable {
+    var snapshot: AppSettingsSnapshot
+    var updatedAt: Date
+}
+
+private struct SyncedMonitoringSettings: Codable {
+    var settings: MonitoringSettings
+    var updatedAt: Date
+}
+
+private struct SyncedDomainNote: Codable, Equatable {
+    var domain: String
+    var text: String
+    var updatedAt: Date
+}
+
+private struct SyncedHistoryMetadata: Codable, Equatable {
+    var domain: String
+    var lastKnownAvailabilityRawValue: String?
+    var lastChangeMessage: String?
+    var lastChangeSeverityRawValue: Int?
+    var certificateWarningLevelRawValue: String
+    var certificateDaysRemaining: Int?
+    var lastObservedAt: Date?
+    var updatedAt: Date
+}
+
+private struct SyncTombstone: Codable, Equatable {
+    var entityType: SyncEntityType
+    var identifier: String
+    var deletedAt: Date
+}
+
+private struct SyncPayload {
+    var trackedDomains: [TrackedDomain]
+    var workflows: [DomainWorkflow]
+    var appSettings: SyncedAppSettings
+    var monitoringSettings: SyncedMonitoringSettings
+    var notesByDomain: [String: SyncedDomainNote]
+    var historyMetadataByDomain: [String: SyncedHistoryMetadata]
+    var tombstones: [SyncTombstone]
+}
+
+private enum SyncDatabaseScope {
+    case privateDatabase
+    case sharedDatabase
+}
+
+private struct SharedRecord<Value> {
+    var record: CKRecord
+    var value: Value
+}
+
+private struct MergedSyncPayload {
+    var payload: SyncPayload
+    var hadConflict: Bool
+    var changedLocalData: Bool
+}
+
+@MainActor
+@Observable
+final class CloudSyncService {
+    static let shared = CloudSyncService()
+
+    var isEnabled: Bool
+    var status: CloudSyncStatus
+    var lastSyncDate: Date?
+    var lastErrorMessage: String?
+    var detailMessage: String
+
+    private var container: CKContainer?
+    private var privateDatabase: CKDatabase?
+    private var sharedDatabase: CKDatabase?
+    private let defaults: UserDefaults
+    private let encoder: JSONEncoder
+    private let decoder: JSONDecoder
+    private let pathMonitor = NWPathMonitor()
+    private let pathMonitorQueue = DispatchQueue(label: "DomainDig.CloudSync.PathMonitor")
+
+    private var isNetworkAvailable = true
+    private var scheduledSyncTask: Task<Void, Never>?
+    private var syncTask: Task<Void, Never>?
+
+    private enum StorageKey {
+        static let isEnabled = "cloudSync.enabled"
+        static let status = "cloudSync.status"
+        static let lastSyncDate = "cloudSync.lastSyncDate"
+        static let lastErrorMessage = "cloudSync.lastErrorMessage"
+        static let detailMessage = "cloudSync.detailMessage"
+        static let appSettingsUpdatedAt = "cloudSync.appSettingsUpdatedAt"
+        static let monitoringSettingsUpdatedAt = "cloudSync.monitoringSettingsUpdatedAt"
+        static let noteUpdatedAtByDomain = "cloudSync.noteUpdatedAtByDomain"
+        static let tombstones = "cloudSync.tombstones"
+        static let monitoringLocalActivationConfirmed = "cloudSync.monitoringLocalActivationConfirmed"
+    }
+
+    private init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+        self.container = nil
+        self.privateDatabase = nil
+        self.sharedDatabase = nil
+        let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
+        self.isEnabled = syncEnabled
+        self.status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
+        self.lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
+        self.lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
+        self.detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
+
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        self.encoder = encoder
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        self.decoder = decoder
+
+        pathMonitor.pathUpdateHandler = { [weak self] path in
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.isNetworkAvailable = path.status == .satisfied
+                if !self.isNetworkAvailable, self.isEnabled, self.status == .syncing {
+                    self.setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
+                }
+            }
+        }
+        pathMonitor.start(queue: pathMonitorQueue)
+    }
+
+    deinit {
+        pathMonitor.cancel()
+    }
+
+    func setSyncEnabled(_ enabled: Bool) {
+        isEnabled = enabled
+        defaults.set(enabled, forKey: StorageKey.isEnabled)
+
+        guard enabled else {
+            scheduledSyncTask?.cancel()
+            syncTask?.cancel()
+            setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
+            return
+        }
+
+        Task {
+            await refreshAvailability()
+            await syncNow(trigger: .manual)
+        }
+    }
+
+    func refreshAvailability() async {
+        guard isEnabled else {
+            setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
+            return
+        }
+
+        guard isNetworkAvailable else {
+            setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
+            return
+        }
+
+        switch await accountStatus() {
+        case .available:
+            if status == .iCloudUnavailable || status == .offline || status == .disabled {
+                setStatus(.synced, detail: syncSummaryDetail(), error: nil)
+            }
+        case .noAccount:
+            setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil)
+        case .restricted:
+            setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil)
+        case .temporarilyUnavailable:
+            setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil)
+        case .unknown:
+            setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil)
+        case .missingEntitlement:
+            setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
+        }
+    }
+
+    func scheduleSyncIfNeeded(trigger: CloudSyncTrigger = .automatic) {
+        guard isEnabled else { return }
+        scheduledSyncTask?.cancel()
+        scheduledSyncTask = Task { [weak self] in
+            try? await Task.sleep(nanoseconds: 900_000_000)
+            guard !Task.isCancelled else { return }
+            await self?.syncNow(trigger: trigger)
+        }
+    }
+
+    func markAppSettingsChanged() {
+        defaults.set(Date(), forKey: StorageKey.appSettingsUpdatedAt)
+        scheduleSyncIfNeeded()
+    }
+
+    func markMonitoringSettingsChanged(localActivationConfirmed: Bool = false) {
+        defaults.set(Date(), forKey: StorageKey.monitoringSettingsUpdatedAt)
+        if localActivationConfirmed {
+            defaults.set(true, forKey: StorageKey.monitoringLocalActivationConfirmed)
+        }
+        scheduleSyncIfNeeded()
+    }
+
+    func markNoteChanged(for domain: String, updatedAt: Date = Date()) {
+        let normalized = Self.normalizeDomain(domain)
+        guard !normalized.isEmpty else { return }
+
+        var noteDates = loadNoteUpdatedAtByDomain()
+        noteDates[normalized] = updatedAt
+        saveNoteUpdatedAtByDomain(noteDates)
+        scheduleSyncIfNeeded()
+    }
+
+    func recordTrackedDomainDeletion(_ trackedDomain: TrackedDomain, deletedAt: Date = Date()) {
+        let normalized = Self.normalizeDomain(trackedDomain.domain)
+        guard !normalized.isEmpty else { return }
+        saveTombstone(.init(entityType: .trackedDomain, identifier: normalized, deletedAt: deletedAt))
+
+        var noteDates = loadNoteUpdatedAtByDomain()
+        noteDates.removeValue(forKey: normalized)
+        saveNoteUpdatedAtByDomain(noteDates)
+
+        scheduleSyncIfNeeded()
+    }
+
+    func recordWorkflowDeletion(_ workflow: DomainWorkflow, deletedAt: Date = Date()) {
+        saveTombstone(.init(entityType: .workflow, identifier: workflow.id.uuidString, deletedAt: deletedAt))
+        scheduleSyncIfNeeded()
+    }
+
+    func recordTrackedDomainReset(_ trackedDomains: [TrackedDomain], deletedAt: Date = Date()) {
+        for trackedDomain in trackedDomains {
+            recordTrackedDomainDeletion(trackedDomain, deletedAt: deletedAt)
+        }
+    }
+
+    func recordWorkflowReset(_ workflows: [DomainWorkflow], deletedAt: Date = Date()) {
+        for workflow in workflows {
+            recordWorkflowDeletion(workflow, deletedAt: deletedAt)
+        }
+    }
+
+    func acceptShare(metadata: CKShare.Metadata) async throws {
+        guard let container = cloudKitContainer() else {
+            throw CloudSyncRuntimeError.missingEntitlement
+        }
+
+        if metadata.participantStatus == .pending {
+            _ = try await container.accept(metadata)
+        }
+
+        await syncNow(trigger: .manual)
+    }
+
+    func existingShare(for entity: ShareableEntity) async throws -> CKShare? {
+        let shareRecordName = try await shareRecordName(for: entity)
+        guard let shareRecordName else { return nil }
+        if let privateShare = try await fetchShare(recordName: shareRecordName, in: .privateDatabase) {
+            return privateShare
+        }
+        return try await fetchShare(recordName: shareRecordName, in: .sharedDatabase)
+    }
+
+    func createShare(for entity: ShareableEntity) async throws -> CKShare {
+        guard let database = cloudKitDatabase(in: .privateDatabase) else {
+            throw CloudSyncRuntimeError.missingEntitlement
+        }
+
+        switch entity {
+        case .trackedDomain(let domain):
+            let normalized = Self.normalizeDomain(domain)
+            let trackedDomain = DomainDataPortabilityService.loadTrackedDomains()
+                .first(where: { Self.normalizeDomain($0.domain) == normalized })
+            guard let trackedDomain else {
+                throw CloudSyncRuntimeError.missingLocalItem
+            }
+
+            let rootRecord = makeTrackedDomainRecord(trackedDomain)
+            let share = CKShare(rootRecord: rootRecord)
+            share[CKShare.SystemFieldKey.title] = normalized as CKRecordValue
+            share.publicPermission = .none
+            _ = try await database.modifyRecords(
+                saving: [rootRecord, share],
+                deleting: [],
+                savePolicy: .allKeys,
+                atomically: true
+            )
+            await syncNow(trigger: .manual)
+            return share
+
+        case .workflow(let identifier):
+            let workflow = DomainDataPortabilityService.loadWorkflows()
+                .first(where: { $0.id == identifier })
+            guard let workflow else {
+                throw CloudSyncRuntimeError.missingLocalItem
+            }
+
+            let rootRecord = makeWorkflowRecord(workflow)
+            let share = CKShare(rootRecord: rootRecord)
+            share[CKShare.SystemFieldKey.title] = workflow.name as CKRecordValue
+            share.publicPermission = .none
+            _ = try await database.modifyRecords(
+                saving: [rootRecord, share],
+                deleting: [],
+                savePolicy: .allKeys,
+                atomically: true
+            )
+            await syncNow(trigger: .manual)
+            return share
+        }
+    }
+
+    func syncNow(trigger: CloudSyncTrigger = .manual) async {
+        guard isEnabled else {
+            setStatus(.disabled, detail: "Sync is optional. Local data stays on this device.", error: nil)
+            return
+        }
+
+        if syncTask != nil {
+            return
+        }
+
+        syncTask = Task { [weak self] in
+            guard let self else { return }
+            await self.performSync(trigger: trigger)
+            await MainActor.run {
+                self.syncTask = nil
+            }
+        }
+
+        await syncTask?.value
+    }
+
+    private func performSync(trigger: CloudSyncTrigger) async {
+        guard cloudKitDatabase(in: .privateDatabase) != nil else {
+            setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
+            return
+        }
+
+        guard isNetworkAvailable else {
+            setStatus(.offline, detail: "Waiting for a network connection.", error: nil)
+            return
+        }
+
+        switch await accountStatus() {
+        case .available:
+            break
+        case .noAccount:
+            setStatus(.iCloudUnavailable, detail: "Sign in to iCloud to sync DomainDig.", error: nil)
+            return
+        case .restricted:
+            setStatus(.iCloudUnavailable, detail: "iCloud access is restricted on this device.", error: nil)
+            return
+        case .temporarilyUnavailable:
+            setStatus(.iCloudUnavailable, detail: "iCloud is temporarily unavailable.", error: nil)
+            return
+        case .unknown:
+            setStatus(.iCloudUnavailable, detail: "DomainDig could not confirm iCloud availability.", error: nil)
+            return
+        case .missingEntitlement:
+            setStatus(.iCloudUnavailable, detail: missingEntitlementMessage, error: nil)
+            return
+        }
+
+        setStatus(.syncing, detail: trigger == .manual ? "Syncing now…" : "Syncing changes…", error: nil)
+
+        do {
+            let local = loadLocalPayload()
+            let remote = try await fetchRemotePayload()
+            let merged = merge(local: local, remote: remote)
+            applyMergedPayload(merged.payload)
+            try await pushMergedPayload(merged.payload)
+
+            lastSyncDate = Date()
+            defaults.set(lastSyncDate, forKey: StorageKey.lastSyncDate)
+
+            if merged.changedLocalData {
+                NotificationCenter.default.post(name: .cloudSyncDidApplyChanges, object: nil)
+            }
+
+            let detail = syncSummaryDetail()
+            if merged.hadConflict {
+                setStatus(.conflictResolved, detail: detail, error: nil)
+            } else {
+                setStatus(.synced, detail: detail, error: nil)
+            }
+        } catch {
+            let mapped = mapSyncError(error)
+            setStatus(mapped.status, detail: mapped.message, error: mapped.errorDetail)
+        }
+    }
+
+    private func loadLocalPayload() -> SyncPayload {
+        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
+        let workflows = DomainDataPortabilityService.loadWorkflows()
+        let appSettings = SyncedAppSettings(
+            snapshot: DomainDataPortabilityService.loadAppSettings(),
+            updatedAt: defaults.object(forKey: StorageKey.appSettingsUpdatedAt) as? Date ?? .distantPast
+        )
+        let monitoringSettings = SyncedMonitoringSettings(
+            settings: DomainDataPortabilityService.loadMonitoringSettings(),
+            updatedAt: defaults.object(forKey: StorageKey.monitoringSettingsUpdatedAt) as? Date ?? .distantPast
+        )
+
+        let noteDates = loadNoteUpdatedAtByDomain()
+        let notesByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in
+            guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
+            let normalized = Self.normalizeDomain(trackedDomain.domain)
+            guard !normalized.isEmpty else { return nil }
+            let noteText = trackedDomain.note ?? ""
+            let updatedAt = noteDates[normalized] ?? trackedDomain.updatedAt
+            return (
+                normalized,
+                SyncedDomainNote(
+                    domain: normalized,
+                    text: noteText,
+                    updatedAt: updatedAt
+                )
+            )
+        })
+
+        let historyMetadataByDomain = Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in
+            guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
+            let normalized = Self.normalizeDomain(trackedDomain.domain)
+            guard !normalized.isEmpty else { return nil }
+            return (
+                normalized,
+                SyncedHistoryMetadata(
+                    domain: normalized,
+                    lastKnownAvailabilityRawValue: trackedDomain.lastKnownAvailability?.rawValue,
+                    lastChangeMessage: trackedDomain.lastChangeSummary?.message,
+                    lastChangeSeverityRawValue: trackedDomain.lastChangeSeverity?.rawValue ?? trackedDomain.lastChangeSummary?.severity.rawValue,
+                    certificateWarningLevelRawValue: trackedDomain.certificateWarningLevel.rawValue,
+                    certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
+                    lastObservedAt: trackedDomain.updatedAt,
+                    updatedAt: trackedDomain.updatedAt
+                )
+            )
+        })
+
+        return SyncPayload(
+            trackedDomains: trackedDomains,
+            workflows: workflows,
+            appSettings: appSettings,
+            monitoringSettings: monitoringSettings,
+            notesByDomain: notesByDomain,
+            historyMetadataByDomain: historyMetadataByDomain,
+            tombstones: loadTombstones()
+        )
+    }
+
+    private func fetchRemotePayload() async throws -> SyncPayload {
+        let privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase)
+        let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase)
+        let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase)
+        let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase)
+
+        let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords(
+            ofType: CloudRecordType.appSettings,
+            in: .privateDatabase
+        )
+        let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords(
+            ofType: CloudRecordType.monitoringSettings,
+            in: .privateDatabase
+        )
+        let domainNotes: [SyncedDomainNote] = try await fetchRecords(
+            ofType: CloudRecordType.domainNote,
+            in: .privateDatabase
+        )
+        let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords(
+            ofType: CloudRecordType.historyMetadata,
+            in: .privateDatabase
+        )
+        let tombstones: [SyncTombstone] = try await fetchRecords(
+            ofType: CloudRecordType.tombstone,
+            in: .privateDatabase
+        )
+
+        let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
+            ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast)
+        let monitoringSettings = monitoringSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
+            ?? SyncedMonitoringSettings(settings: DomainDataPortabilityService.loadMonitoringSettings(), updatedAt: .distantPast)
+
+        return SyncPayload(
+            trackedDomains: privateTrackedDomains + sharedTrackedDomains,
+            workflows: privateWorkflows + sharedWorkflows,
+            appSettings: appSettings,
+            monitoringSettings: monitoringSettings,
+            notesByDomain: Dictionary(uniqueKeysWithValues: domainNotes.map { (Self.normalizeDomain($0.domain), $0) }),
+            historyMetadataByDomain: Dictionary(uniqueKeysWithValues: historyMetadata.map { (Self.normalizeDomain($0.domain), $0) }),
+            tombstones: tombstones
+        )
+    }
+
+    private func pushMergedPayload(_ payload: SyncPayload) async throws {
+        guard let privateDatabase = cloudKitDatabase(in: .privateDatabase) else {
+            throw CloudSyncRuntimeError.missingEntitlement
+        }
+
+        var privateRecordsToSave: [CKRecord] = []
+        var sharedRecordsToSave: [CKRecord] = []
+        var recordIDsToDelete: [CKRecord.ID] = []
+
+        for trackedDomain in payload.trackedDomains {
+            switch trackedDomain.collaboration?.scope ?? .privateDatabase {
+            case .privateDatabase:
+                privateRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
+            case .sharedDatabase:
+                if trackedDomain.collaboration?.canEdit == true {
+                    sharedRecordsToSave.append(makeTrackedDomainRecord(trackedDomain))
+                }
+            }
+        }
+
+        for workflow in payload.workflows {
+            switch workflow.collaboration?.scope ?? .privateDatabase {
+            case .privateDatabase:
+                privateRecordsToSave.append(makeWorkflowRecord(workflow))
+            case .sharedDatabase:
+                if workflow.collaboration?.canEdit == true {
+                    sharedRecordsToSave.append(makeWorkflowRecord(workflow))
+                }
+            }
+        }
+
+        privateRecordsToSave.append(makeAppSettingsRecord(payload.appSettings))
+        privateRecordsToSave.append(makeMonitoringSettingsRecord(payload.monitoringSettings))
+        privateRecordsToSave.append(contentsOf: payload.notesByDomain.values.map(makeDomainNoteRecord))
+        privateRecordsToSave.append(contentsOf: payload.historyMetadataByDomain.values.map(makeHistoryMetadataRecord))
+        privateRecordsToSave.append(contentsOf: payload.tombstones.map(makeTombstoneRecord))
+
+        for tombstone in payload.tombstones {
+            switch tombstone.entityType {
+            case .trackedDomain:
+                let identifier = tombstone.identifier
+                recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier)))
+                recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier)))
+                recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier)))
+            case .workflow:
+                recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier)))
+            }
+        }
+
+        _ = try await privateDatabase.modifyRecords(
+            saving: privateRecordsToSave,
+            deleting: Array(Set(recordIDsToDelete)),
+            savePolicy: .changedKeys,
+            atomically: false
+        )
+
+        if !sharedRecordsToSave.isEmpty, let sharedDatabase = cloudKitDatabase(in: .sharedDatabase) {
+            _ = try await sharedDatabase.modifyRecords(
+                saving: sharedRecordsToSave,
+                deleting: [],
+                savePolicy: .changedKeys,
+                atomically: false
+            )
+        }
+    }
+
+    private func applyMergedPayload(_ payload: SyncPayload) {
+        DomainDataPortabilityService.saveTrackedDomains(payload.trackedDomains)
+        DomainDataPortabilityService.saveWorkflows(payload.workflows)
+        DomainDataPortabilityService.saveAppSettings(payload.appSettings.snapshot)
+        DomainDataPortabilityService.saveMonitoringSettings(payload.monitoringSettings.settings)
+
+        defaults.set(payload.appSettings.updatedAt, forKey: StorageKey.appSettingsUpdatedAt)
+        defaults.set(payload.monitoringSettings.updatedAt, forKey: StorageKey.monitoringSettingsUpdatedAt)
+        saveNoteUpdatedAtByDomain(Dictionary(uniqueKeysWithValues: payload.notesByDomain.map { ($0.key, $0.value.updatedAt) }))
+        saveTombstones(payload.tombstones)
+    }
+
+    private func merge(local: SyncPayload, remote: SyncPayload) -> MergedSyncPayload {
+        var hadConflict = false
+
+        let tombstones = latestTombstones(local.tombstones + remote.tombstones)
+
+        let trackedDomainsResult = mergeTrackedDomains(local.trackedDomains, remote.trackedDomains)
+        hadConflict = hadConflict || trackedDomainsResult.hadConflict
+        var trackedDomains = applyTrackedDomainTombstones(trackedDomainsResult.domains, tombstones: tombstones)
+
+        let workflowsResult = mergeWorkflows(local.workflows, remote.workflows)
+        hadConflict = hadConflict || workflowsResult.hadConflict
+        let workflows = applyWorkflowTombstones(workflowsResult.workflows, tombstones: tombstones)
+
+        let notesResult = mergeNotes(local.notesByDomain, remote.notesByDomain)
+        hadConflict = hadConflict || notesResult.hadConflict
+        trackedDomains = applyNotes(notesResult.notesByDomain, to: trackedDomains, tombstones: tombstones)
+
+        let historyResult = mergeHistoryMetadata(local.historyMetadataByDomain, remote.historyMetadataByDomain)
+        hadConflict = hadConflict || historyResult.hadConflict
+        trackedDomains = applyHistoryMetadata(historyResult.historyMetadataByDomain, to: trackedDomains, tombstones: tombstones)
+
+        let appSettingsResult = mergeAppSettings(local.appSettings, remote.appSettings)
+        hadConflict = hadConflict || appSettingsResult.hadConflict
+
+        let monitoringResult = mergeMonitoringSettings(local.monitoringSettings, remote.monitoringSettings)
+        hadConflict = hadConflict || monitoringResult.hadConflict
+
+        let mergedPayload = SyncPayload(
+            trackedDomains: trackedDomains,
+            workflows: workflows,
+            appSettings: appSettingsResult.settings,
+            monitoringSettings: monitoringResult.settings,
+            notesByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedDomainNote)? in
+                guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
+                let normalized = Self.normalizeDomain(trackedDomain.domain)
+                guard let note = notesResult.notesByDomain[normalized] else { return nil }
+                return (normalized, note)
+            }),
+            historyMetadataByDomain: Dictionary(uniqueKeysWithValues: trackedDomains.compactMap { trackedDomain -> (String, SyncedHistoryMetadata)? in
+                guard trackedDomain.collaboration?.scope != .sharedDatabase else { return nil }
+                let normalized = Self.normalizeDomain(trackedDomain.domain)
+                guard let metadata = historyResult.historyMetadataByDomain[normalized] else { return nil }
+                return (normalized, metadata)
+            }),
+            tombstones: tombstones
+        )
+
+        let changedLocalData =
+            mergedPayload.trackedDomains != local.trackedDomains
+            || mergedPayload.workflows != local.workflows
+            || !sameAppSettings(mergedPayload.appSettings.snapshot, local.appSettings.snapshot)
+            || mergedPayload.monitoringSettings.settings != local.monitoringSettings.settings
+
+        return MergedSyncPayload(
+            payload: mergedPayload,
+            hadConflict: hadConflict,
+            changedLocalData: changedLocalData
+        )
+    }
+
+    private func mergeTrackedDomains(_ lhs: [TrackedDomain], _ rhs: [TrackedDomain]) -> (domains: [TrackedDomain], hadConflict: Bool) {
+        var mergedByDomain = Dictionary(uniqueKeysWithValues: lhs.map { (Self.normalizeDomain($0.domain), Self.payloadTrackedDomain($0)) })
+        var hadConflict = false
+
+        for trackedDomain in rhs.map(Self.payloadTrackedDomain) {
+            let key = Self.normalizeDomain(trackedDomain.domain)
+            guard !key.isEmpty else { continue }
+
+            if let existing = mergedByDomain[key] {
+                if existing != trackedDomain {
+                    hadConflict = true
+                }
+                mergedByDomain[key] = mergedTrackedDomain(existing, trackedDomain)
+            } else {
+                mergedByDomain[key] = trackedDomain
+            }
+        }
+
+        let domains = mergedByDomain.values.sorted {
+            if $0.updatedAt != $1.updatedAt {
+                return $0.updatedAt > $1.updatedAt
+            }
+            return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+        }
+
+        return (domains, hadConflict)
+    }
+
+    private func mergeWorkflows(_ lhs: [DomainWorkflow], _ rhs: [DomainWorkflow]) -> (workflows: [DomainWorkflow], hadConflict: Bool) {
+        var merged = Dictionary(uniqueKeysWithValues: lhs.map { ($0.id, normalizedWorkflow($0)) })
+        var hadConflict = false
+
+        for workflow in rhs.map(normalizedWorkflow) {
+            if let existing = merged[workflow.id] {
+                if existing != workflow {
+                    hadConflict = true
+                }
+                let winner = preferredWorkflow(existing, workflow)
+                merged[workflow.id] = DomainWorkflow(
+                    id: existing.id,
+                    name: winner.name,
+                    domains: deduplicated(existing.domains + workflow.domains),
+                    createdAt: min(existing.createdAt, workflow.createdAt),
+                    updatedAt: max(existing.updatedAt, workflow.updatedAt),
+                    notes: winner.notes ?? existing.notes ?? workflow.notes,
+                    collaboration: preferredCollaboration(existing.collaboration, workflow.collaboration)
+                )
+            } else {
+                merged[workflow.id] = workflow
+            }
+        }
+
+        return (
+            merged.values.sorted {
+                if $0.updatedAt != $1.updatedAt {
+                    return $0.updatedAt > $1.updatedAt
+                }
+                return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+            },
+            hadConflict
+        )
+    }
+
+    private func mergeNotes(
+        _ lhs: [String: SyncedDomainNote],
+        _ rhs: [String: SyncedDomainNote]
+    ) -> (notesByDomain: [String: SyncedDomainNote], hadConflict: Bool) {
+        var merged = lhs
+        var hadConflict = false
+
+        for (domain, remoteNote) in rhs {
+            if let localNote = merged[domain], localNote != remoteNote {
+                hadConflict = true
+                merged[domain] = localNote.updatedAt >= remoteNote.updatedAt ? localNote : remoteNote
+            } else {
+                merged[domain] = remoteNote
+            }
+        }
+
+        return (merged, hadConflict)
+    }
+
+    private func mergeHistoryMetadata(
+        _ lhs: [String: SyncedHistoryMetadata],
+        _ rhs: [String: SyncedHistoryMetadata]
+    ) -> (historyMetadataByDomain: [String: SyncedHistoryMetadata], hadConflict: Bool) {
+        var merged = lhs
+        var hadConflict = false
+
+        for (domain, remoteMetadata) in rhs {
+            if let localMetadata = merged[domain], localMetadata != remoteMetadata {
+                hadConflict = true
+                merged[domain] = localMetadata.updatedAt >= remoteMetadata.updatedAt ? localMetadata : remoteMetadata
+            } else {
+                merged[domain] = remoteMetadata
+            }
+        }
+
+        return (merged, hadConflict)
+    }
+
+    private func mergeAppSettings(_ lhs: SyncedAppSettings, _ rhs: SyncedAppSettings) -> (settings: SyncedAppSettings, hadConflict: Bool) {
+        let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
+        let loser = winner.updatedAt == lhs.updatedAt ? rhs : lhs
+
+        let merged = SyncedAppSettings(
+            snapshot: AppSettingsSnapshot(
+                recentSearches: deduplicated(winner.snapshot.recentSearches + loser.snapshot.recentSearches).prefix(20).map { $0 },
+                savedDomains: deduplicated(winner.snapshot.savedDomains + loser.snapshot.savedDomains),
+                resolverURLString: winner.snapshot.resolverURLString,
+                appDensityRawValue: winner.snapshot.appDensityRawValue
+            ),
+            updatedAt: max(lhs.updatedAt, rhs.updatedAt)
+        )
+
+        return (merged, !sameAppSettings(lhs.snapshot, rhs.snapshot) || lhs.updatedAt != rhs.updatedAt)
+    }
+
+    private func mergeMonitoringSettings(
+        _ lhs: SyncedMonitoringSettings,
+        _ rhs: SyncedMonitoringSettings
+    ) -> (settings: SyncedMonitoringSettings, hadConflict: Bool) {
+        let winner = lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
+        var merged = winner
+
+        if merged.settings.isEnabled && !defaults.bool(forKey: StorageKey.monitoringLocalActivationConfirmed) {
+            merged.settings.isEnabled = false
+        }
+
+        if merged.settings.alertsEnabled {
+            merged.settings.alertsEnabled = false
+        }
+
+        merged.updatedAt = max(lhs.updatedAt, rhs.updatedAt)
+        return (merged, lhs.settings != rhs.settings || lhs.updatedAt != rhs.updatedAt)
+    }
+
+    private func applyTrackedDomainTombstones(_ trackedDomains: [TrackedDomain], tombstones: [SyncTombstone]) -> [TrackedDomain] {
+        let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
+            guard tombstone.entityType == .trackedDomain else { return nil }
+            return (tombstone.identifier, tombstone)
+        })
+
+        return trackedDomains.filter { trackedDomain in
+            let key = Self.normalizeDomain(trackedDomain.domain)
+            guard let tombstone = tombstonesByDomain[key] else { return true }
+            return trackedDomain.updatedAt > tombstone.deletedAt
+        }
+    }
+
+    private func applyWorkflowTombstones(_ workflows: [DomainWorkflow], tombstones: [SyncTombstone]) -> [DomainWorkflow] {
+        let tombstonesByIdentifier = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
+            guard tombstone.entityType == .workflow else { return nil }
+            return (tombstone.identifier, tombstone)
+        })
+
+        return workflows.filter { workflow in
+            guard let tombstone = tombstonesByIdentifier[workflow.id.uuidString] else { return true }
+            return workflow.updatedAt > tombstone.deletedAt
+        }
+    }
+
+    private func applyNotes(
+        _ notesByDomain: [String: SyncedDomainNote],
+        to trackedDomains: [TrackedDomain],
+        tombstones: [SyncTombstone]
+    ) -> [TrackedDomain] {
+        let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
+            guard tombstone.entityType == .trackedDomain else { return nil }
+            return (tombstone.identifier, tombstone)
+        })
+
+        return trackedDomains.map { trackedDomain in
+            guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
+            let normalized = Self.normalizeDomain(trackedDomain.domain)
+            guard let note = notesByDomain[normalized] else { return trackedDomain }
+            if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= note.updatedAt {
+                return trackedDomain
+            }
+
+            var updated = trackedDomain
+            updated.note = emptyToNil(note.text)
+            return updated
+        }
+    }
+
+    private func applyHistoryMetadata(
+        _ historyMetadataByDomain: [String: SyncedHistoryMetadata],
+        to trackedDomains: [TrackedDomain],
+        tombstones: [SyncTombstone]
+    ) -> [TrackedDomain] {
+        let tombstonesByDomain = Dictionary(uniqueKeysWithValues: tombstones.compactMap { tombstone -> (String, SyncTombstone)? in
+            guard tombstone.entityType == .trackedDomain else { return nil }
+            return (tombstone.identifier, tombstone)
+        })
+
+        return trackedDomains.map { trackedDomain in
+            guard trackedDomain.collaboration?.scope != .sharedDatabase else { return trackedDomain }
+            let normalized = Self.normalizeDomain(trackedDomain.domain)
+            guard let metadata = historyMetadataByDomain[normalized] else { return trackedDomain }
+            if let tombstone = tombstonesByDomain[normalized], tombstone.deletedAt >= metadata.updatedAt {
+                return trackedDomain
+            }
+
+            var updated = trackedDomain
+            updated.lastKnownAvailability = metadata.lastKnownAvailabilityRawValue.flatMap(DomainAvailabilityStatus.init(rawValue:))
+            updated.lastChangeSeverity = metadata.lastChangeSeverityRawValue.flatMap(ChangeSeverity.init(rawValue:))
+            updated.certificateWarningLevel = CertificateWarningLevel(rawValue: metadata.certificateWarningLevelRawValue) ?? .none
+            updated.certificateDaysRemaining = metadata.certificateDaysRemaining
+
+            if let message = metadata.lastChangeMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
+                updated.lastChangeSummary = DomainChangeSummary(
+                    hasChanges: true,
+                    changedSections: [],
+                    message: message,
+                    severity: updated.lastChangeSeverity ?? .medium,
+                    impactClassification: .warning,
+                    generatedAt: metadata.lastObservedAt ?? metadata.updatedAt
+                )
+            }
+
+            return updated
+        }
+    }
+
+    private func latestTombstones(_ tombstones: [SyncTombstone]) -> [SyncTombstone] {
+        var latest: [String: SyncTombstone] = [:]
+
+        for tombstone in tombstones {
+            let key = "\(tombstone.entityType.rawValue):\(tombstone.identifier)"
+            if let existing = latest[key] {
+                latest[key] = existing.deletedAt >= tombstone.deletedAt ? existing : tombstone
+            } else {
+                latest[key] = tombstone
+            }
+        }
+
+        return latest.values.sorted { $0.deletedAt > $1.deletedAt }
+    }
+
+    private func saveTombstone(_ tombstone: SyncTombstone) {
+        var tombstones = loadTombstones()
+        tombstones.append(tombstone)
+        saveTombstones(latestTombstones(tombstones))
+    }
+
+    private func loadTombstones() -> [SyncTombstone] {
+        guard let data = defaults.data(forKey: StorageKey.tombstones),
+              let tombstones = try? decoder.decode([SyncTombstone].self, from: data) else {
+            return []
+        }
+        return tombstones
+    }
+
+    private func saveTombstones(_ tombstones: [SyncTombstone]) {
+        if let data = try? encoder.encode(Array(tombstones.prefix(200))) {
+            defaults.set(data, forKey: StorageKey.tombstones)
+        }
+    }
+
+    private func loadNoteUpdatedAtByDomain() -> [String: Date] {
+        guard let data = defaults.data(forKey: StorageKey.noteUpdatedAtByDomain),
+              let values = try? decoder.decode([String: Date].self, from: data) else {
+            return [:]
+        }
+        return values
+    }
+
+    private func saveNoteUpdatedAtByDomain(_ values: [String: Date]) {
+        if let data = try? encoder.encode(values) {
+            defaults.set(data, forKey: StorageKey.noteUpdatedAtByDomain)
+        }
+    }
+
+    private func setStatus(_ status: CloudSyncStatus, detail: String, error: String?) {
+        self.status = status
+        self.detailMessage = detail
+        self.lastErrorMessage = error
+
+        defaults.set(status.rawValue, forKey: StorageKey.status)
+        defaults.set(detail, forKey: StorageKey.detailMessage)
+        if let error {
+            defaults.set(error, forKey: StorageKey.lastErrorMessage)
+        } else {
+            defaults.removeObject(forKey: StorageKey.lastErrorMessage)
+        }
+    }
+
+    private func syncSummaryDetail() -> String {
+        let dateLabel = lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
+        return "Data stays in your private iCloud account. Last sync: \(dateLabel)."
+    }
+
+    private func accountStatus() async -> AvailabilityState {
+        guard let container = cloudKitContainer() else {
+            return .missingEntitlement
+        }
+
+        return await withCheckedContinuation { continuation in
+            container.accountStatus { status, error in
+                if let error {
+                    let ckError = error as? CKError
+                    if ckError?.code == .notAuthenticated {
+                        continuation.resume(returning: AvailabilityState.noAccount)
+                        return
+                    }
+                    continuation.resume(returning: AvailabilityState.unknown)
+                    return
+                }
+
+                switch status {
+                case .available:
+                    continuation.resume(returning: AvailabilityState.available)
+                case .noAccount:
+                    continuation.resume(returning: AvailabilityState.noAccount)
+                case .restricted:
+                    continuation.resume(returning: AvailabilityState.restricted)
+                case .temporarilyUnavailable:
+                    continuation.resume(returning: AvailabilityState.temporarilyUnavailable)
+                case .couldNotDetermine:
+                    continuation.resume(returning: AvailabilityState.unknown)
+                @unknown default:
+                    continuation.resume(returning: AvailabilityState.unknown)
+                }
+            }
+        }
+    }
+
+    private func fetchRecords<T: Decodable>(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] {
+        guard let database = cloudKitDatabase(in: scope) else {
+            if scope == .sharedDatabase {
+                return []
+            }
+            throw CloudSyncRuntimeError.missingEntitlement
+        }
+
+        let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
+        var results: [T] = []
+        var cursor: CKQueryOperation.Cursor?
+
+        repeat {
+            let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
+            if let cursor {
+                batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
+            } else {
+                batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
+            }
+
+            for (_, result) in batch.matchResults {
+                switch result {
+                case .success(let record):
+                    guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
+                    if let decoded = try? decoder.decode(T.self, from: payload) {
+                        results.append(decoded)
+                    } else {
+                        lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
+                    }
+                case .failure:
+                    continue
+                }
+            }
+
+            cursor = batch.queryCursor
+        } while cursor != nil
+
+        return results
+    }
+
+    private func fetchSharedRecords<T: Decodable>(
+        ofType recordType: String,
+        in scope: SyncDatabaseScope
+    ) async throws -> [SharedRecord<T>] {
+        guard let database = cloudKitDatabase(in: scope) else {
+            if scope == .sharedDatabase {
+                return []
+            }
+            throw CloudSyncRuntimeError.missingEntitlement
+        }
+
+        let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
+        var results: [SharedRecord<T>] = []
+        var cursor: CKQueryOperation.Cursor?
+
+        repeat {
+            let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
+            if let cursor {
+                batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
+            } else {
+                batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
+            }
+
+            for (_, result) in batch.matchResults {
+                switch result {
+                case .success(let record):
+                    guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
+                    if let decoded = try? decoder.decode(T.self, from: payload) {
+                        results.append(SharedRecord(record: record, value: decoded))
+                    } else {
+                        lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
+                    }
+                case .failure:
+                    continue
+                }
+            }
+
+            cursor = batch.queryCursor
+        } while cursor != nil
+
+        return results
+    }
+
+    private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] {
+        let records: [SharedRecord<TrackedDomain>] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope)
+        let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
+
+        return records.map { entry in
+            var trackedDomain = Self.payloadTrackedDomain(entry.value)
+            trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
+            return trackedDomain
+        }
+    }
+
+    private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] {
+        let records: [SharedRecord<DomainWorkflow>] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope)
+        let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
+
+        return records.map { entry in
+            var workflow = normalizedWorkflow(entry.value)
+            workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
+            return workflow
+        }
+    }
+
+    private func sharesByRecordID(
+        for records: [CKRecord],
+        in scope: SyncDatabaseScope
+    ) async throws -> [CKRecord.ID: CKShare] {
+        guard let database = cloudKitDatabase(in: scope) else { return [:] }
+
+        let shareReferences = records.reduce(into: [CKRecord.ID: CKRecord.Reference]()) { partialResult, record in
+            if let reference = record.share {
+                partialResult[record.recordID] = reference
+            }
+        }
+        guard !shareReferences.isEmpty else { return [:] }
+
+        let fetched = try await database.records(for: Array(Set(shareReferences.values.map(\.recordID))))
+        var sharesByRootRecordID: [CKRecord.ID: CKShare] = [:]
+
+        for (rootRecordID, shareReference) in shareReferences {
+            guard case .success(let shareRecord) = fetched[shareReference.recordID],
+                  let share = shareRecord as? CKShare else {
+                continue
+            }
+            sharesByRootRecordID[rootRecordID] = share
+        }
+
+        return sharesByRootRecordID
+    }
+
+    private func collaborationMetadata(
+        for record: CKRecord,
+        share: CKShare?,
+        scope: SyncDatabaseScope
+    ) -> CollaborationMetadata {
+        let ownership: CollaborationOwnership
+        let permission: CollaborationPermission
+
+        if let participant = share?.currentUserParticipant {
+            ownership = participant.role == .owner ? .owner : .participant
+            permission = participant.permission == .readOnly ? .readOnly : .editable
+        } else {
+            ownership = scope == .sharedDatabase ? .participant : .owner
+            permission = scope == .sharedDatabase ? .readOnly : .editable
+        }
+
+        return CollaborationMetadata(
+            scope: scope == .privateDatabase ? .privateDatabase : .sharedDatabase,
+            ownership: ownership,
+            permission: permission,
+            shareRecordName: share?.recordID.recordName
+        )
+    }
+
+    private func fetchShare(recordName: String, in scope: SyncDatabaseScope) async throws -> CKShare? {
+        guard let database = cloudKitDatabase(in: scope) else { return nil }
+        let results = try await database.records(for: [CKRecord.ID(recordName: recordName)])
+        guard case .success(let shareRecord) = results[CKRecord.ID(recordName: recordName)] else {
+            return nil
+        }
+        return shareRecord as? CKShare
+    }
+
+    private func shareRecordName(for entity: ShareableEntity) async throws -> String? {
+        switch entity {
+        case .trackedDomain(let domain):
+            let normalized = Self.normalizeDomain(domain)
+            return DomainDataPortabilityService.loadTrackedDomains()
+                .first(where: { Self.normalizeDomain($0.domain) == normalized })?
+                .collaboration?.shareRecordName
+        case .workflow(let identifier):
+            return DomainDataPortabilityService.loadWorkflows()
+                .first(where: { $0.id == identifier })?
+                .collaboration?.shareRecordName
+        }
+    }
+
+    private func makeTrackedDomainRecord(_ trackedDomain: TrackedDomain) -> CKRecord {
+        let normalized = Self.normalizeDomain(trackedDomain.domain)
+        let sanitized = Self.payloadTrackedDomain(trackedDomain)
+        return makeRecord(
+            type: CloudRecordType.trackedDomain,
+            recordName: trackedDomainRecordName(for: normalized),
+            payload: sanitized,
+            extraFields: [
+                CloudRecordKey.domain: normalized as CKRecordValue,
+                CloudRecordKey.updatedAt: sanitized.updatedAt as CKRecordValue
+            ]
+        )
+    }
+
+    private func makeWorkflowRecord(_ workflow: DomainWorkflow) -> CKRecord {
+        let normalized = normalizedWorkflow(workflow)
+        return makeRecord(
+            type: CloudRecordType.workflow,
+            recordName: workflowRecordName(for: normalized.id.uuidString),
+            payload: normalized,
+            extraFields: [
+                CloudRecordKey.identifier: normalized.id.uuidString as CKRecordValue,
+                CloudRecordKey.updatedAt: normalized.updatedAt as CKRecordValue
+            ]
+        )
+    }
+
+    private func makeAppSettingsRecord(_ settings: SyncedAppSettings) -> CKRecord {
+        makeRecord(
+            type: CloudRecordType.appSettings,
+            recordName: "app-settings",
+            payload: settings,
+            extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
+        )
+    }
+
+    private func makeMonitoringSettingsRecord(_ settings: SyncedMonitoringSettings) -> CKRecord {
+        makeRecord(
+            type: CloudRecordType.monitoringSettings,
+            recordName: "monitoring-settings",
+            payload: settings,
+            extraFields: [CloudRecordKey.updatedAt: settings.updatedAt as CKRecordValue]
+        )
+    }
+
+    private func makeDomainNoteRecord(_ note: SyncedDomainNote) -> CKRecord {
+        makeRecord(
+            type: CloudRecordType.domainNote,
+            recordName: domainNoteRecordName(for: note.domain),
+            payload: note,
+            extraFields: [
+                CloudRecordKey.domain: note.domain as CKRecordValue,
+                CloudRecordKey.updatedAt: note.updatedAt as CKRecordValue
+            ]
+        )
+    }
+
+    private func makeHistoryMetadataRecord(_ metadata: SyncedHistoryMetadata) -> CKRecord {
+        makeRecord(
+            type: CloudRecordType.historyMetadata,
+            recordName: historyMetadataRecordName(for: metadata.domain),
+            payload: metadata,
+            extraFields: [
+                CloudRecordKey.domain: metadata.domain as CKRecordValue,
+                CloudRecordKey.updatedAt: metadata.updatedAt as CKRecordValue
+            ]
+        )
+    }
+
+    private func makeTombstoneRecord(_ tombstone: SyncTombstone) -> CKRecord {
+        makeRecord(
+            type: CloudRecordType.tombstone,
+            recordName: tombstoneRecordName(for: tombstone.entityType, identifier: tombstone.identifier),
+            payload: tombstone,
+            extraFields: [
+                CloudRecordKey.entityType: tombstone.entityType.rawValue as CKRecordValue,
+                CloudRecordKey.identifier: tombstone.identifier as CKRecordValue,
+                CloudRecordKey.deletedAt: tombstone.deletedAt as CKRecordValue
+            ]
+        )
+    }
+
+    private func makeRecord<T: Encodable>(
+        type: String,
+        recordName: String,
+        payload: T,
+        extraFields: [String: CKRecordValue]
+    ) -> CKRecord {
+        let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName))
+        record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue?
+        for (key, value) in extraFields {
+            record[key] = value
+        }
+        return record
+    }
+
+    private func mapSyncError(_ error: Error) -> (status: CloudSyncStatus, message: String, errorDetail: String?) {
+        if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingEntitlement {
+            return (
+                .iCloudUnavailable,
+                missingEntitlementMessage,
+                nil
+            )
+        }
+
+        if let runtimeError = error as? CloudSyncRuntimeError, runtimeError == .missingLocalItem {
+            return (.error, "The shared item is no longer available locally.", nil)
+        }
+
+        guard let ckError = error as? CKError else {
+            return (.error, "Sync failed, but local data is still available.", error.localizedDescription)
+        }
+
+        switch ckError.code {
+        case .networkUnavailable, .networkFailure, .serviceUnavailable:
+            return (.offline, "Sync is waiting for a stable network connection.", ckError.localizedDescription)
+        case .notAuthenticated:
+            return (.iCloudUnavailable, "Sign in to iCloud to sync DomainDig.", ckError.localizedDescription)
+        case .quotaExceeded:
+            return (.error, "iCloud storage is full. DomainDig kept working locally.", ckError.localizedDescription)
+        case .permissionFailure, .badContainer, .missingEntitlement:
+            return (.iCloudUnavailable, "CloudKit access is unavailable for this build.", ckError.localizedDescription)
+        case .partialFailure:
+            return (.error, "Some iCloud records could not sync. Local data remains available.", ckError.localizedDescription)
+        default:
+            return (.error, "Sync failed, but local data is still available.", ckError.localizedDescription)
+        }
+    }
+
+    private enum AvailabilityState {
+        case available
+        case noAccount
+        case restricted
+        case temporarilyUnavailable
+        case unknown
+        case missingEntitlement
+    }
+
+    private enum CloudSyncRuntimeError: Error, Equatable {
+        case missingEntitlement
+        case missingLocalItem
+    }
+
+    private static func normalizeDomain(_ value: String) -> String {
+        value
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/")
+            .first?
+            .lowercased() ?? ""
+    }
+
+    private func cloudKitContainer() -> CKContainer? {
+        if let container {
+            return container
+        }
+
+        if isEntitlementConfigurationAvailable() {
+            let container = CKContainer.default()
+            self.container = container
+            return container
+        }
+
+        return nil
+    }
+
+    private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? {
+        switch scope {
+        case .privateDatabase:
+            if let privateDatabase {
+                return privateDatabase
+            }
+        case .sharedDatabase:
+            if let sharedDatabase {
+                return sharedDatabase
+            }
+        }
+
+        guard let container = cloudKitContainer() else {
+            return nil
+        }
+
+        switch scope {
+        case .privateDatabase:
+            let database = container.privateCloudDatabase
+            self.privateDatabase = database
+            return database
+        case .sharedDatabase:
+            let database = container.sharedCloudDatabase
+            self.sharedDatabase = database
+            return database
+        }
+    }
+
+    private func isEntitlementConfigurationAvailable() -> Bool {
+        if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") != nil {
+            return true
+        }
+
+        if Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.ubiquity-container-identifiers") != nil {
+            return true
+        }
+
+        if let entitlementsURL = Bundle.main.url(forResource: "archived-expanded-entitlements", withExtension: "xcent"),
+           let data = try? Data(contentsOf: entitlementsURL),
+           let contents = String(data: data, encoding: .utf8) {
+            return contents.contains("com.apple.developer.icloud-services")
+                && (contents.contains("CloudKit") || contents.contains("CloudKit-Anonymous"))
+        }
+
+        return false
+    }
+
+    private var missingEntitlementMessage: String {
+        "This build does not have the CloudKit entitlement required for iCloud sync."
+    }
+
+    private static func payloadTrackedDomain(_ 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: nil,
+            lastChangeSummary: nil,
+            lastChangeSeverity: trackedDomain.lastChangeSeverity,
+            certificateWarningLevel: trackedDomain.certificateWarningLevel,
+            certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
+            lastMonitoredAt: trackedDomain.lastMonitoredAt,
+            lastAlertAt: trackedDomain.lastAlertAt,
+            collaboration: trackedDomain.collaboration
+        )
+    }
+
+    private func mergedTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
+        let winner = preferredTrackedDomain(lhs, rhs)
+
+        return TrackedDomain(
+            id: lhs.id,
+            domain: Self.normalizeDomain(lhs.domain.isEmpty ? rhs.domain : lhs.domain),
+            createdAt: min(lhs.createdAt, rhs.createdAt),
+            updatedAt: max(lhs.updatedAt, rhs.updatedAt),
+            note: winner.note ?? lhs.note ?? rhs.note,
+            isPinned: winner.isPinned,
+            monitoringEnabled: winner.monitoringEnabled,
+            lastKnownAvailability: winner.lastKnownAvailability ?? lhs.lastKnownAvailability ?? rhs.lastKnownAvailability,
+            lastSnapshotID: nil,
+            lastChangeSummary: nil,
+            lastChangeSeverity: winner.lastChangeSeverity ?? lhs.lastChangeSeverity ?? rhs.lastChangeSeverity,
+            certificateWarningLevel: higherCertificateWarningLevel(lhs.certificateWarningLevel, rhs.certificateWarningLevel),
+            certificateDaysRemaining: winner.certificateDaysRemaining ?? lhs.certificateDaysRemaining ?? rhs.certificateDaysRemaining,
+            lastMonitoredAt: [lhs.lastMonitoredAt, rhs.lastMonitoredAt].compactMap { $0 }.max(),
+            lastAlertAt: [lhs.lastAlertAt, rhs.lastAlertAt].compactMap { $0 }.max(),
+            collaboration: preferredCollaboration(lhs.collaboration, rhs.collaboration)
+        )
+    }
+
+    private 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: emptyToNil(workflow.notes ?? ""),
+            collaboration: workflow.collaboration
+        )
+    }
+
+    private func preferredTrackedDomain(_ lhs: TrackedDomain, _ rhs: TrackedDomain) -> TrackedDomain {
+        let lhsRank = collaborationRank(lhs.collaboration)
+        let rhsRank = collaborationRank(rhs.collaboration)
+        if lhsRank != rhsRank {
+            return lhsRank > rhsRank ? lhs : rhs
+        }
+        return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
+    }
+
+    private func preferredWorkflow(_ lhs: DomainWorkflow, _ rhs: DomainWorkflow) -> DomainWorkflow {
+        let lhsRank = collaborationRank(lhs.collaboration)
+        let rhsRank = collaborationRank(rhs.collaboration)
+        if lhsRank != rhsRank {
+            return lhsRank > rhsRank ? lhs : rhs
+        }
+        return lhs.updatedAt >= rhs.updatedAt ? lhs : rhs
+    }
+
+    private func preferredCollaboration(
+        _ lhs: CollaborationMetadata?,
+        _ rhs: CollaborationMetadata?
+    ) -> CollaborationMetadata? {
+        guard let lhs else { return rhs }
+        guard let rhs else { return lhs }
+        let lhsRank = collaborationRank(lhs)
+        let rhsRank = collaborationRank(rhs)
+        if lhsRank != rhsRank {
+            return lhsRank > rhsRank ? lhs : rhs
+        }
+        if lhs.shareRecordName != nil {
+            return lhs
+        }
+        return rhs
+    }
+
+    private func collaborationRank(_ collaboration: CollaborationMetadata?) -> Int {
+        guard let collaboration else { return 0 }
+        switch collaboration.ownership {
+        case .owner:
+            return collaboration.isShared ? 3 : 2
+        case .participant:
+            return collaboration.permission == .editable ? 1 : 0
+        }
+    }
+
+    private func emptyToNil(_ value: String) -> String? {
+        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.isEmpty ? nil : trimmed
+    }
+
+    private func sameAppSettings(_ lhs: AppSettingsSnapshot, _ rhs: AppSettingsSnapshot) -> Bool {
+        lhs.recentSearches == rhs.recentSearches
+            && lhs.savedDomains == rhs.savedDomains
+            && lhs.resolverURLString == rhs.resolverURLString
+            && lhs.appDensityRawValue == rhs.appDensityRawValue
+    }
+
+    private func deduplicated(_ values: [String]) -> [String] {
+        var seen = Set<String>()
+        return values.filter { seen.insert($0).inserted }
+    }
+
+    private 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 func trackedDomainRecordName(for normalizedDomain: String) -> String {
+        "tracked-domain:\(normalizedDomain)"
+    }
+
+    private func workflowRecordName(for identifier: String) -> String {
+        "workflow:\(identifier)"
+    }
+
+    private func domainNoteRecordName(for normalizedDomain: String) -> String {
+        "domain-note:\(normalizedDomain)"
+    }
+
+    private func historyMetadataRecordName(for normalizedDomain: String) -> String {
+        "history-metadata:\(normalizedDomain)"
+    }
+
+    private func tombstoneRecordName(for entityType: SyncEntityType, identifier: String) -> String {
+        "tombstone:\(entityType.rawValue):\(identifier)"
+    }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index e1d0a99..0386211 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2421,6 +2421,7 @@ struct SettingsView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
     @State private var purchaseService = PurchaseService.shared
+    @State private var cloudSyncService = CloudSyncService.shared
     @AppStorage(DNSResolverOption.userDefaultsKey)
     private var storedResolverURL = DNSResolverOption.defaultURLString
     @AppStorage(AppDensity.userDefaultsKey)
@@ -2480,6 +2481,43 @@ struct SettingsView: View {
                 }
             }
 
+            Section("iCloud Sync") {
+                Toggle(
+                    "Enable iCloud Sync",
+                    isOn: Binding(
+                        get: { cloudSyncService.isEnabled },
+                        set: { cloudSyncService.setSyncEnabled($0) }
+                    )
+                )
+
+                LabeledContent("Status", value: cloudSyncService.status.title)
+                LabeledContent(
+                    "Last Sync",
+                    value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
+                )
+
+                Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
+                    Task {
+                        await cloudSyncService.syncNow(trigger: .manual)
+                    }
+                }
+                .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
+
+                Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.")
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+
+                Text(cloudSyncService.detailMessage)
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
+
+                if let lastErrorMessage = cloudSyncService.lastErrorMessage {
+                    Text(lastErrorMessage)
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.red)
+                }
+            }
+
             Section("Monitoring") {
                 Toggle(
                     "Enable Background Monitoring",
@@ -2713,7 +2751,7 @@ struct SettingsView: View {
 
             Section("About") {
                 LabeledContent("Version", value: appVersion)
-                LabeledContent("Storage", value: "Local-only")
+                LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
                 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
             }
         }
@@ -2817,18 +2855,37 @@ struct SettingsView: View {
             Task {
                 await viewModel.refreshUsageCredits()
                 await viewModel.refreshMonitoringAuthorizationStatus()
+                await cloudSyncService.refreshAvailability()
             }
         }
         .onChange(of: resolverOption) { _, newValue in
             guard let presetURL = newValue.urlString else {
                 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+                viewModel.persistCurrentAppSettings(
+                    resolverURLString: storedResolverURL,
+                    appDensityRawValue: storedDensity
+                )
                 return
             }
             storedResolverURL = presetURL
+            viewModel.persistCurrentAppSettings(
+                resolverURLString: storedResolverURL,
+                appDensityRawValue: storedDensity
+            )
         }
         .onChange(of: customResolverURL) { _, newValue in
             guard resolverOption == .custom else { return }
             storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+            viewModel.persistCurrentAppSettings(
+                resolverURLString: storedResolverURL,
+                appDensityRawValue: storedDensity
+            )
+        }
+        .onChange(of: storedDensity) { _, newValue in
+            viewModel.persistCurrentAppSettings(
+                resolverURLString: storedResolverURL,
+                appDensityRawValue: newValue
+            )
         }
     }
 
diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift
index 59f6afa..b671fbc 100644
--- a/DomainDig/DomainAvailabilityService.swift
+++ b/DomainDig/DomainAvailabilityService.swift
@@ -10,17 +10,14 @@ struct DomainAvailabilityService {
         }
 
         if await checkViaRDAP(domain: normalizedDomain) == .registered {
-            debugLog("rdap", domain: normalizedDomain, status: .registered)
             return DomainAvailabilityResult(domain: normalizedDomain, status: .registered)
         }
 
         let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
         if fallbackStatus == .registered {
-            debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration")
             return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown)
         }
 
-        debugLog("fallback", domain: normalizedDomain, status: fallbackStatus)
         return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
     }
 
@@ -40,9 +37,6 @@ struct DomainAvailabilityService {
 
     private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
         let status = await RDAPService.registrationStatus(for: domain)
-        if status == nil {
-            debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
-        }
         return status
     }
 
@@ -53,7 +47,6 @@ struct DomainAvailabilityService {
                 return .registered
             }
         } catch {
-            debugLog("dns-a-error", domain: domain, details: error.localizedDescription)
         }
 
         do {
@@ -63,7 +56,6 @@ struct DomainAvailabilityService {
             }
             return .unknown
         } catch {
-            debugLog("dns-ns-error", domain: domain, details: error.localizedDescription)
             return .unknown
         }
     }
@@ -99,16 +91,4 @@ struct DomainAvailabilityService {
             .trimmingCharacters(in: .whitespacesAndNewlines)
             .lowercased()
     }
-
-    private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) {
-        #if DEBUG
-        print("[Availability] \(domain) -> \(status.rawValue) via \(method)")
-        #endif
-    }
-
-    private static func debugLog(_ method: String, domain: String, details: String) {
-        #if DEBUG
-        print("[Availability] \(domain) -> \(method): \(details)")
-        #endif
-    }
 }
diff --git a/DomainDig/DomainDig.entitlements b/DomainDig/DomainDig.entitlements
new file mode 100644
index 0000000..d792729
--- /dev/null
+++ b/DomainDig/DomainDig.entitlements
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.developer.icloud-container-identifiers</key>
+    <array>
+        <string>iCloud.net.cleberg.DomainDig</string>
+    </array>
+    <key>com.apple.developer.icloud-services</key>
+    <array>
+        <string>CloudKit</string>
+    </array>
+</dict>
+</plist>
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index f7cd15e..4bb1b8f 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -9,10 +9,12 @@ import SwiftUI
 
 @main
 struct DomainDigApp: App {
+    @UIApplicationDelegateAdaptor(DomainDigAppDelegate.self) private var appDelegate
     @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
+    @State private var cloudSyncService = CloudSyncService.shared
 
     init() {
         LocalNotificationService.shared.configureForegroundPresentation()
@@ -25,9 +27,16 @@ struct DomainDigApp: App {
                 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
                 .task {
                     let _ = purchaseService.currentTier
+                    let _ = cloudSyncService.status
                     await purchaseService.refreshEntitlements()
                     viewModel.refreshMonitoringState()
                     await viewModel.refreshMonitoringAuthorizationStatus()
+                    await cloudSyncService.refreshAvailability()
+                    cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
+                    viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+                }
+                .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
+                    viewModel.refreshPersistedData()
                     viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
                 }
         }
@@ -36,7 +45,9 @@ struct DomainDigApp: App {
             viewModel.refreshMonitoringState()
             Task {
                 await viewModel.refreshMonitoringAuthorizationStatus()
+                await cloudSyncService.refreshAvailability()
             }
+            cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
             viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
         }
     }
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 527bf5e..02e09f7 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -607,12 +607,14 @@ final class DomainViewModel {
             savedDomains.append(searchedDomain)
         }
         DomainDataPortabilityService.saveSavedDomains(savedDomains)
+        CloudSyncService.shared.markAppSettingsChanged()
         refreshDataLifecycleSummary()
     }
 
     func removeSavedDomains(at offsets: IndexSet) {
         savedDomains.remove(atOffsets: offsets)
         DomainDataPortabilityService.saveSavedDomains(savedDomains)
+        CloudSyncService.shared.markAppSettingsChanged()
         refreshDataLifecycleSummary()
     }
 
@@ -641,7 +643,12 @@ final class DomainViewModel {
                 domain: normalizedDomain,
                 createdAt: Date(),
                 updatedAt: Date(),
-                lastKnownAvailability: availabilityStatus
+                lastKnownAvailability: availabilityStatus,
+                collaboration: CollaborationMetadata(
+                    scope: .privateDatabase,
+                    ownership: .owner,
+                    permission: .editable
+                )
             ),
             at: 0
         )
@@ -666,7 +673,9 @@ final class DomainViewModel {
     }
 
     func deleteTrackedDomains(at offsets: IndexSet) {
-        let ids = offsets.map { sortedTrackedDomains[$0].id }
+        let removedDomains = offsets.map { sortedTrackedDomains[$0] }
+        let ids = removedDomains.map(\.id)
+        CloudSyncService.shared.recordTrackedDomainReset(removedDomains)
         trackedDomains.removeAll { ids.contains($0.id) }
         history.indices.forEach { index in
             if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
@@ -679,6 +688,8 @@ final class DomainViewModel {
     }
 
     func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
+        guard canDelete(trackedDomain) else { return }
+        CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
         trackedDomains.removeAll { $0.id == trackedDomain.id }
         history.indices.forEach { index in
             if history[index].trackedDomainID == trackedDomain.id {
@@ -691,14 +702,19 @@ final class DomainViewModel {
     }
 
     func togglePinned(for trackedDomain: TrackedDomain) {
+        guard canEdit(trackedDomain) else { return }
         guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
         trackedDomains[index].isPinned.toggle()
+        trackedDomains[index].updatedAt = Date()
         persistTrackedDomains()
     }
 
     func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
+        guard canEdit(trackedDomain) else { return }
         guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
         trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+        trackedDomains[index].updatedAt = Date()
+        CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt)
         persistTrackedDomains()
     }
 
@@ -725,6 +741,7 @@ final class DomainViewModel {
     }
 
     func clearWorkflows() {
+        CloudSyncService.shared.recordWorkflowReset(workflows)
         workflows.removeAll()
         latestWorkflowRunSummary = nil
         persistWorkflows()
@@ -732,6 +749,7 @@ final class DomainViewModel {
     }
 
     func clearTrackedDomains() {
+        CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
         trackedDomains.removeAll()
         refreshingTrackedDomainID = nil
         persistTrackedDomains()
@@ -742,6 +760,7 @@ final class DomainViewModel {
     func clearRecentSearches() {
         recentSearches.removeAll()
         DomainDataPortabilityService.saveRecentSearches([])
+        CloudSyncService.shared.markAppSettingsChanged()
         refreshDataLifecycleSummary()
     }
 
@@ -789,13 +808,13 @@ final class DomainViewModel {
         }
 
         monitoringSettings.isEnabled = isEnabled
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: isEnabled)
         monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
     }
 
     func setMonitoringScope(_ scope: MonitoringScope) {
         monitoringSettings.scope = scope
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
     }
 
     func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
@@ -804,7 +823,7 @@ final class DomainViewModel {
             return
         }
         monitoringSettings.frequency = frequency
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
         monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
     }
 
@@ -815,7 +834,7 @@ final class DomainViewModel {
             return
         }
         monitoringSettings.alertFilter = filter
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
     }
 
     func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
@@ -825,7 +844,7 @@ final class DomainViewModel {
             return
         }
         monitoringSettings.alertsEnabled = isEnabled
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
     }
 
     func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
@@ -836,24 +855,27 @@ final class DomainViewModel {
         } else {
             monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
         }
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
     }
 
     func toggleMonitoring(for trackedDomain: TrackedDomain) {
+        guard canEdit(trackedDomain) else { return }
         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()
+        trackedDomains[index].updatedAt = Date()
         MonitoringStorage.saveTrackedDomains(trackedDomains)
+        CloudSyncService.shared.scheduleSyncIfNeeded()
         sanitizeMonitoringSelection()
     }
 
     func requestMonitoringNotificationAuthorization() async {
         let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
         monitoringSettings.alertsEnabled = granted
-        persistMonitoringSettings()
+        persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
         await refreshMonitoringAuthorizationStatus()
     }
 
@@ -991,6 +1013,32 @@ final class DomainViewModel {
         }
     }
 
+    func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
+        trackedDomain.collaboration?.canEdit ?? true
+    }
+
+    func canDelete(_ trackedDomain: TrackedDomain) -> Bool {
+        trackedDomain.collaboration?.isOwner ?? true
+    }
+
+    func collaborationLabel(for trackedDomain: TrackedDomain) -> String? {
+        guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil }
+        return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
+    }
+
+    func canEdit(_ workflow: DomainWorkflow) -> Bool {
+        workflow.collaboration?.canEdit ?? true
+    }
+
+    func canDelete(_ workflow: DomainWorkflow) -> Bool {
+        workflow.collaboration?.isOwner ?? true
+    }
+
+    func collaborationLabel(for workflow: DomainWorkflow) -> String? {
+        guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
+        return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
+    }
+
     @discardableResult
     func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
         let normalizedDomains = normalizedDomains(domains)
@@ -1006,7 +1054,12 @@ final class DomainViewModel {
             domains: normalizedDomains,
             createdAt: Date(),
             updatedAt: Date(),
-            notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+            notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
+            collaboration: CollaborationMetadata(
+                scope: .privateDatabase,
+                ownership: .owner,
+                permission: .editable
+            )
         )
         workflows.insert(workflow, at: 0)
         persistWorkflows()
@@ -1014,6 +1067,7 @@ final class DomainViewModel {
     }
 
     func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
+        guard canEdit(workflow) else { return }
         guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
         let normalizedDomains = normalizedDomains(domains)
         let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1027,6 +1081,8 @@ final class DomainViewModel {
     }
 
     func deleteWorkflow(_ workflow: DomainWorkflow) {
+        guard canDelete(workflow) else { return }
+        CloudSyncService.shared.recordWorkflowDeletion(workflow)
         workflows.removeAll { $0.id == workflow.id }
         if latestWorkflowRunSummary?.workflowID == workflow.id {
             latestWorkflowRunSummary = nil
@@ -1039,6 +1095,7 @@ final class DomainViewModel {
     }
 
     func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
         guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
         let mergedDomains = normalizedDomains(workflows[index].domains + domains)
         guard mergedDomains != workflows[index].domains else { return }
@@ -1048,6 +1105,7 @@ final class DomainViewModel {
     }
 
     func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
         guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
         workflows[index].domains.remove(atOffsets: offsets)
         workflows[index].updatedAt = Date()
@@ -1055,6 +1113,7 @@ final class DomainViewModel {
     }
 
     func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
+        guard canEdit(workflow) else { return }
         guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
         workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
         workflows[index].updatedAt = Date()
@@ -1349,9 +1408,25 @@ final class DomainViewModel {
         let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
         refreshPersistedData()
         portabilityStatusMessage = result.summary
+        CloudSyncService.shared.markAppSettingsChanged()
+        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: monitoringSettings.isEnabled)
+        CloudSyncService.shared.scheduleSyncIfNeeded(trigger: .import)
         return result
     }
 
+    func persistCurrentAppSettings(resolverURLString: String, appDensityRawValue: String) {
+        DomainDataPortabilityService.saveAppSettings(
+            AppSettingsSnapshot(
+                recentSearches: recentSearches,
+                savedDomains: savedDomains,
+                resolverURLString: resolverURLString,
+                appDensityRawValue: appDensityRawValue
+            )
+        )
+        CloudSyncService.shared.markAppSettingsChanged()
+        refreshDataLifecycleSummary()
+    }
+
     func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
         let reports = workflowReports(from: summary, changedOnly: changedOnly)
         let base = DomainReportExporter.batchText(
@@ -2011,17 +2086,20 @@ final class DomainViewModel {
 
     private func persistTrackedDomains() {
         DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
+        CloudSyncService.shared.scheduleSyncIfNeeded()
         refreshDataLifecycleSummary()
     }
 
-    private func persistMonitoringSettings() {
+    private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
         monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
         MonitoringStorage.saveSettings(monitoringSettings)
+        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
     }
 
     private func sanitizeMonitoringSelection() {
         monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
         MonitoringStorage.saveSettings(monitoringSettings)
+        CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
     }
 
     private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -2150,6 +2228,7 @@ final class DomainViewModel {
             recentSearches = Array(recentSearches.prefix(Self.maxRecent))
         }
         DomainDataPortabilityService.saveRecentSearches(recentSearches)
+        CloudSyncService.shared.markAppSettingsChanged()
         refreshDataLifecycleSummary()
     }
 
@@ -2723,6 +2802,7 @@ final class DomainViewModel {
 
     private func persistWorkflows() {
         DomainDataPortabilityService.saveWorkflows(workflows)
+        CloudSyncService.shared.scheduleSyncIfNeeded()
         refreshDataLifecycleSummary()
     }
 
diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist
index a24a71e..5b0e7e6 100644
--- a/DomainDig/Info.plist
+++ b/DomainDig/Info.plist
@@ -10,6 +10,8 @@
 	<array>
 		<string>net.cleberg.DomainDig.monitor.refresh</string>
 	</array>
+	<key>CKSharingSupported</key>
+	<true/>
 	<key>UIBackgroundModes</key>
 	<array>
 		<string>fetch</string>
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 7715b4a..25d1ba2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -332,6 +332,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
     var createdAt: Date
     var updatedAt: Date
     var notes: String?
+    var collaboration: CollaborationMetadata?
 
     init(
         id: UUID = UUID(),
@@ -339,7 +340,8 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
         domains: [String],
         createdAt: Date = Date(),
         updatedAt: Date = Date(),
-        notes: String? = nil
+        notes: String? = nil,
+        collaboration: CollaborationMetadata? = nil
     ) {
         self.id = id
         self.name = name
@@ -347,6 +349,7 @@ struct DomainWorkflow: Codable, Identifiable, Equatable {
         self.createdAt = createdAt
         self.updatedAt = updatedAt
         self.notes = notes
+        self.collaboration = collaboration
     }
 }
 
@@ -584,6 +587,70 @@ enum PremiumCapability: String, Codable {
     case advancedExports
 }
 
+enum CollaborationPermission: String, Codable, Equatable {
+    case readOnly
+    case editable
+
+    var title: String {
+        switch self {
+        case .readOnly:
+            return "Read only"
+        case .editable:
+            return "Editable"
+        }
+    }
+}
+
+enum CollaborationOwnership: String, Codable, Equatable {
+    case owner
+    case participant
+
+    var title: String {
+        switch self {
+        case .owner:
+            return "Owned by you"
+        case .participant:
+            return "Shared with you"
+        }
+    }
+}
+
+enum CollaborationScope: String, Codable, Equatable {
+    case privateDatabase
+    case sharedDatabase
+}
+
+struct CollaborationMetadata: Codable, Equatable {
+    var scope: CollaborationScope
+    var ownership: CollaborationOwnership
+    var permission: CollaborationPermission
+    var shareRecordName: String?
+
+    init(
+        scope: CollaborationScope,
+        ownership: CollaborationOwnership,
+        permission: CollaborationPermission,
+        shareRecordName: String? = nil
+    ) {
+        self.scope = scope
+        self.ownership = ownership
+        self.permission = permission
+        self.shareRecordName = shareRecordName
+    }
+
+    var isShared: Bool {
+        shareRecordName != nil || scope == .sharedDatabase
+    }
+
+    var canEdit: Bool {
+        permission == .editable
+    }
+
+    var isOwner: Bool {
+        ownership == .owner
+    }
+}
+
 struct TrackedDomain: Codable, Identifiable, Equatable {
     let id: UUID
     var domain: String
@@ -600,6 +667,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
     var certificateDaysRemaining: Int?
     var lastMonitoredAt: Date?
     var lastAlertAt: Date?
+    var collaboration: CollaborationMetadata?
 
     init(
         id: UUID = UUID(),
@@ -616,7 +684,8 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         certificateWarningLevel: CertificateWarningLevel = .none,
         certificateDaysRemaining: Int? = nil,
         lastMonitoredAt: Date? = nil,
-        lastAlertAt: Date? = nil
+        lastAlertAt: Date? = nil,
+        collaboration: CollaborationMetadata? = nil
     ) {
         self.id = id
         self.domain = domain
@@ -633,6 +702,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         self.certificateDaysRemaining = certificateDaysRemaining
         self.lastMonitoredAt = lastMonitoredAt
         self.lastAlertAt = lastAlertAt
+        self.collaboration = collaboration
     }
 
     init(from decoder: Decoder) throws {
@@ -652,6 +722,7 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
         certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
         lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
         lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
+        collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
     }
 }
 
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 968914f..1a38282 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -193,10 +193,12 @@ struct WatchlistView: View {
             }
             .tint(.cyan)
 
-            Button(role: .destructive) {
-                viewModel.deleteTrackedDomain(trackedDomain)
-            } label: {
-                Label("Delete", systemImage: "trash")
+            if viewModel.canDelete(trackedDomain) {
+                Button(role: .destructive) {
+                    viewModel.deleteTrackedDomain(trackedDomain)
+                } label: {
+                    Label("Delete", systemImage: "trash")
+                }
             }
         }
         .contextMenu {
@@ -219,11 +221,21 @@ struct WatchlistView: View {
             } label: {
                 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
             }
+            .disabled(!viewModel.canEdit(trackedDomain))
 
-            Button(role: .destructive) {
-                viewModel.deleteTrackedDomain(trackedDomain)
+            Button {
+                // The system sharing UI manages participants and permissions.
             } label: {
-                Label("Delete", systemImage: "trash")
+                Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
+            }
+            .disabled(true)
+
+            if viewModel.canDelete(trackedDomain) {
+                Button(role: .destructive) {
+                    viewModel.deleteTrackedDomain(trackedDomain)
+                } label: {
+                    Label("Delete", systemImage: "trash")
+                }
             }
         }
         .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -287,6 +299,12 @@ struct WatchlistRowView: View {
                 .font(appDensity.font(.caption2))
                 .foregroundStyle(.secondary)
 
+            if let collaboration = trackedDomain.collaboration, collaboration.isShared {
+                Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
+                    .font(appDensity.font(.caption2))
+                    .foregroundStyle(.secondary)
+            }
+
             HStack(spacing: 8) {
                 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
                 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
@@ -340,6 +358,17 @@ struct WatchlistRowView: View {
     @ViewBuilder
     private var indicatorRow: some View {
         HStack(spacing: 8) {
+            if trackedDomain.collaboration?.isShared == true {
+                AppStatusBadgeView(
+                    model: .init(
+                        title: "Shared",
+                        systemImage: "person.2.fill",
+                        foregroundColor: .cyan,
+                        backgroundColor: .cyan.opacity(0.16)
+                    )
+                )
+            }
+
             AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
 
             if trackedDomain.certificateWarningLevel != .none {
@@ -369,6 +398,7 @@ struct TrackedDomainDetailView: View {
     @State private var noteDraft = ""
     @State private var isEditingNote = false
     @State private var showRerunOptions = false
+    @State private var shareEntity: ShareableEntity?
 
     private var liveTrackedDomain: TrackedDomain {
         viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -410,6 +440,7 @@ struct TrackedDomainDetailView: View {
                 } label: {
                     Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
                 }
+                .disabled(!viewModel.canEdit(liveTrackedDomain))
 
                 Button {
                     viewModel.toggleMonitoring(for: liveTrackedDomain)
@@ -419,6 +450,7 @@ struct TrackedDomainDetailView: View {
                         systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
                     )
                 }
+                .disabled(!viewModel.canEdit(liveTrackedDomain))
 
                 Button {
                     noteDraft = liveTrackedDomain.note ?? ""
@@ -426,6 +458,13 @@ struct TrackedDomainDetailView: View {
                 } label: {
                     Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
                 }
+                .disabled(!viewModel.canEdit(liveTrackedDomain))
+
+                Button {
+                    shareEntity = .trackedDomain(liveTrackedDomain.domain)
+                } label: {
+                    Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
+                }
             }
             .listRowBackground(Color(.systemGray6).opacity(0.5))
 
@@ -518,5 +557,8 @@ struct TrackedDomainDetailView: View {
                 }
             }
         }
+        .sheet(item: $shareEntity) { entity in
+            CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
+        }
     }
 }
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index 5025a0b..06ae96b 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -129,6 +129,11 @@ private struct WorkflowRowView: View {
     var body: some View {
         VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
             HStack(alignment: .firstTextBaseline, spacing: 8) {
+                if workflow.collaboration?.isShared == true {
+                    Image(systemName: "person.2.fill")
+                        .font(.caption2)
+                        .foregroundStyle(.cyan)
+                }
                 Text(workflow.name)
                     .font(appDensity.font(.callout, design: .default, weight: .semibold))
                     .foregroundStyle(.primary)
@@ -143,6 +148,12 @@ private struct WorkflowRowView: View {
                 .font(appDensity.font(.caption2))
                 .foregroundStyle(.secondary)
 
+            if let collaboration = workflow.collaboration, collaboration.isShared {
+                Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
+                    .font(appDensity.font(.caption2))
+                    .foregroundStyle(.secondary)
+            }
+
             if let notes = workflow.notes, !notes.isEmpty {
                 Text(notes)
                     .font(appDensity.font(.caption))
@@ -163,6 +174,7 @@ struct WorkflowDetailView: View {
     @State private var showingEditor = false
     @State private var draftDomain = ""
     @State private var includeAllExports = false
+    @State private var shareEntity: ShareableEntity?
 
     private var workflow: DomainWorkflow? {
         viewModel.workflow(withID: workflowID)
@@ -186,6 +198,11 @@ struct WorkflowDetailView: View {
                                 .font(appDensity.font(.caption))
                                 .foregroundStyle(.secondary)
                         }
+                        if let collaboration = workflow.collaboration, collaboration.isShared {
+                            Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
+                                .font(appDensity.font(.caption))
+                                .foregroundStyle(.secondary)
+                        }
                     }
 
                     Section("Actions") {
@@ -207,6 +224,13 @@ struct WorkflowDetailView: View {
                         } label: {
                             Label("Edit Workflow", systemImage: "pencil")
                         }
+                        .disabled(!viewModel.canEdit(workflow))
+
+                        Button {
+                            shareEntity = .workflow(workflow.id)
+                        } label: {
+                            Label(workflow.collaboration?.isShared == true ? "Manage Share" : "Share Workflow", systemImage: "person.2")
+                        }
 
                         if latestSummary != nil {
                             Toggle("Export all domains", isOn: $includeAllExports)
@@ -263,6 +287,7 @@ struct WorkflowDetailView: View {
                                 viewModel.addDomains([draftDomain], to: workflow)
                                 draftDomain = ""
                             }
+                            .disabled(!viewModel.canEdit(workflow))
                         }
                     }
 
@@ -288,9 +313,11 @@ struct WorkflowDetailView: View {
                                 .buttonStyle(.plain)
                             }
                             .onDelete { offsets in
+                                guard viewModel.canEdit(workflow) else { return }
                                 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
                             }
                             .onMove { offsets, destination in
+                                guard viewModel.canEdit(workflow) else { return }
                                 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
                             }
                         }
@@ -302,6 +329,7 @@ struct WorkflowDetailView: View {
                 .toolbar {
                     if !workflow.domains.isEmpty {
                         EditButton()
+                            .disabled(!viewModel.canEdit(workflow))
                     }
                 }
                 .sheet(isPresented: $showingEditor) {
@@ -310,6 +338,9 @@ struct WorkflowDetailView: View {
                 .sheet(item: workflowSummaryBinding) { summary in
                     WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
                 }
+                .sheet(item: $shareEntity) { entity in
+                    CloudSharingSheet(entity: entity, title: workflow.name)
+                }
             } else {
                 Text("Workflow not found")
                     .font(appDensity.font(.callout))
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index 8c433d8..bb9dd80 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -428,6 +428,8 @@ struct DomainDigCLI {
                domaindig backup export [path]
                domaindig backup import <path> [--replace]
                domaindig backup validate <path> [--json]
+        
+        note: the CLI remains local-only and does not sync with iCloud yet.
         """
     }
 }