krz/domain-dig

an ios app for DNS & SSL analysis

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

1bc0c44013e577a8fe61bcef5fd1590c0692a0a4

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T06:33:28Z

Fix the build, and group HistoryEntry's 72-parameter initializer

The build was broken on main. 49a9998 renamed CloudflareDNSAnswer.TTL to .ttl
and added CodingKeys so the JSON is unchanged, but left the call site in
DNSLookupService passing TTL:. CI caught it — the xcodebuild test job on #64
failed — and the PR was merged anyway.

Closes #65. A full inspection produces 73 stored properties, and passing them
as one flat list made a 72-parameter initializer (swift:S107) that no call site
could read. They are grouped into seven structs by what they describe, so both
call sites now name what they are filling in.

The stored properties stay flat, deliberately. HistoryEntry is persisted to
UserDefaults and read back out of backup files, and both decode paths drop
entries that will not parse rather than raising — nesting a value would change
the encoded shape and silently discard history written by an older build. The
groups exist at the call boundary only; CodingKeys, init(from:) and every
stored property are byte for byte unchanged.

HistoryEntryCodableTests pins that: the encoded key set, a JSON round trip, and
a save/load through the real persistence path. It had no test coverage at all
before, which is what made the format easy to break silently.
 .gitignore                                    |   3 +
 DomainDig.xcodeproj/project.pbxproj           |   4 +
 DomainDig/DNSLookupService.swift              |   2 +-
 DomainDig/DomainMonitoringService.swift       | 134 ++++++------
 DomainDig/DomainViewModel.swift               | 158 +++++++-------
 DomainDig/Models.swift                        | 297 ++++++++++++++++----------
 DomainDigTests/HistoryEntryCodableTests.swift | 129 +++++++++++
 7 files changed, 487 insertions(+), 240 deletions(-)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2fbb608
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+
+# Xcode per-user state
+xcuserdata/
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 0e243ee..c108caa 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
 		47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
 		4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; };
 		81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
+		A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */; };
 		8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
 		8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
 		8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
@@ -98,6 +99,7 @@
 		CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; };
 		DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
 		E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; };
+		A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryEntryCodableTests.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -235,6 +237,7 @@
 				0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
 				435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */,
 				499B99A1866999A38AC475F5 /* AppInfoTests.swift */,
+				A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */,
 			);
 			name = DomainDigTests;
 			path = DomainDigTests;
@@ -489,6 +492,7 @@
 			files = (
 				E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */,
 				81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */,
+				A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */,
 				47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
 				C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
 				A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift
index 00ca13b..5904a72 100644
--- a/DomainDig/DNSLookupService.swift
+++ b/DomainDig/DNSLookupService.swift
@@ -364,7 +364,7 @@ struct DNSLookupService {
             answers.append(.init(
                 name: name,
                 type: type,
-                TTL: ttl,
+                ttl: ttl,
                 data: parsedValue
             ))
         }
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index eb6a581..b902f75 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -520,66 +520,80 @@ final class DomainMonitoringService {
         }
 
         let entry = HistoryEntry(
-            domain: snapshot.domain,
-            timestamp: snapshot.timestamp,
-            trackedDomainID: trackedDomainID,
-            note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
-            dnsSections: snapshot.dnsSections,
-            sslInfo: snapshot.sslInfo,
-            httpHeaders: snapshot.httpHeaders,
-            reachabilityResults: snapshot.reachabilityResults,
-            ipGeolocation: snapshot.ipGeolocation,
-            emailSecurity: snapshot.emailSecurity,
-            mtaSts: snapshot.emailSecurity?.mtaSts,
-            ownership: snapshot.ownership,
-            ownershipHistory: snapshot.ownershipHistory,
-            ptrRecord: snapshot.ptrRecord,
-            redirectChain: snapshot.redirectChain,
-            subdomains: snapshot.subdomains,
-            extendedSubdomains: snapshot.extendedSubdomains,
-            dnsHistory: snapshot.dnsHistory,
-            domainPricing: snapshot.domainPricing,
-            reputation: snapshot.reputation,
-            portScanResults: snapshot.portScanResults,
-            hstsPreloaded: snapshot.hstsPreloaded,
-            availabilityResult: snapshot.availabilityResult,
-            suggestions: snapshot.suggestions,
-            appVersion: snapshot.appVersion,
-            resultSource: snapshot.resultSource,
-            dataSources: snapshot.dataSources,
-            provenanceBySection: snapshot.provenanceBySection,
-            availabilityConfidence: snapshot.availabilityConfidence,
-            ownershipConfidence: snapshot.ownershipConfidence,
-            subdomainConfidence: snapshot.subdomainConfidence,
-            emailSecurityConfidence: snapshot.emailSecurityConfidence,
-            geolocationConfidence: snapshot.geolocationConfidence,
-            errorDetails: snapshot.errorDetails,
-            isPartialSnapshot: snapshot.isPartialSnapshot,
-            validationIssues: snapshot.validationIssues,
-            resolverDisplayName: snapshot.resolverDisplayName,
-            resolverURLString: snapshot.resolverURLString,
-            totalLookupDurationMs: snapshot.totalLookupDurationMs,
-            primaryIP: Self.primaryIPAddress(from: snapshot),
-            finalRedirectURL: snapshot.redirectChain.last?.url,
-            tlsStatusSummary: Self.tlsSummary(from: snapshot),
-            emailSecuritySummary: Self.emailSummary(from: snapshot),
-            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
-            changeSummary: changeSummary,
-            sslError: snapshot.sslError,
-            httpHeadersError: snapshot.httpHeadersError,
-            reachabilityError: snapshot.reachabilityError,
-            ipGeolocationError: snapshot.ipGeolocationError,
-            emailSecurityError: snapshot.emailSecurityError,
-            ownershipError: snapshot.ownershipError,
-            ownershipHistoryError: snapshot.ownershipHistoryError,
-            ptrError: snapshot.ptrError,
-            redirectChainError: snapshot.redirectChainError,
-            subdomainsError: snapshot.subdomainsError,
-            extendedSubdomainsError: snapshot.extendedSubdomainsError,
-            dnsHistoryError: snapshot.dnsHistoryError,
-            domainPricingError: snapshot.domainPricingError,
-            reputationError: snapshot.reputationError,
-            portScanError: snapshot.portScanError
+            identity: HistoryEntry.Identity(
+                domain: snapshot.domain,
+                timestamp: snapshot.timestamp,
+                trackedDomainID: trackedDomainID,
+                note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note
+            ),
+            inspection: HistoryEntry.Inspection(
+                dnsSections: snapshot.dnsSections,
+                sslInfo: snapshot.sslInfo,
+                httpHeaders: snapshot.httpHeaders,
+                reachabilityResults: snapshot.reachabilityResults,
+                ipGeolocation: snapshot.ipGeolocation,
+                emailSecurity: snapshot.emailSecurity,
+                mtaSts: snapshot.emailSecurity?.mtaSts,
+                ptrRecord: snapshot.ptrRecord,
+                redirectChain: snapshot.redirectChain,
+                subdomains: snapshot.subdomains,
+                extendedSubdomains: snapshot.extendedSubdomains,
+                dnsHistory: snapshot.dnsHistory,
+                portScanResults: snapshot.portScanResults,
+                hstsPreloaded: snapshot.hstsPreloaded,
+                availabilityResult: snapshot.availabilityResult,
+                suggestions: snapshot.suggestions
+            ),
+            registration: HistoryEntry.Registration(
+                ownership: snapshot.ownership,
+                ownershipHistory: snapshot.ownershipHistory,
+                domainPricing: snapshot.domainPricing
+            ),
+            intelligence: HistoryEntry.Intelligence(
+                reputation: snapshot.reputation
+            ),
+            provenance: HistoryEntry.Provenance(
+                appVersion: snapshot.appVersion,
+                resultSource: snapshot.resultSource,
+                dataSources: snapshot.dataSources,
+                provenanceBySection: snapshot.provenanceBySection,
+                availabilityConfidence: snapshot.availabilityConfidence,
+                ownershipConfidence: snapshot.ownershipConfidence,
+                subdomainConfidence: snapshot.subdomainConfidence,
+                emailSecurityConfidence: snapshot.emailSecurityConfidence,
+                geolocationConfidence: snapshot.geolocationConfidence,
+                isPartialSnapshot: snapshot.isPartialSnapshot,
+                validationIssues: snapshot.validationIssues,
+                resolverDisplayName: snapshot.resolverDisplayName,
+                resolverURLString: snapshot.resolverURLString,
+                totalLookupDurationMs: snapshot.totalLookupDurationMs
+            ),
+            summary: HistoryEntry.Summary(
+                primaryIP: Self.primaryIPAddress(from: snapshot),
+                finalRedirectURL: snapshot.redirectChain.last?.url,
+                tlsStatusSummary: Self.tlsSummary(from: snapshot),
+                emailSecuritySummary: Self.emailSummary(from: snapshot),
+                httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
+                changeSummary: changeSummary
+            ),
+            failures: HistoryEntry.Failures(
+                errorDetails: snapshot.errorDetails,
+                sslError: snapshot.sslError,
+                httpHeadersError: snapshot.httpHeadersError,
+                reachabilityError: snapshot.reachabilityError,
+                ipGeolocationError: snapshot.ipGeolocationError,
+                emailSecurityError: snapshot.emailSecurityError,
+                ownershipError: snapshot.ownershipError,
+                ownershipHistoryError: snapshot.ownershipHistoryError,
+                ptrError: snapshot.ptrError,
+                redirectChainError: snapshot.redirectChainError,
+                subdomainsError: snapshot.subdomainsError,
+                extendedSubdomainsError: snapshot.extendedSubdomainsError,
+                dnsHistoryError: snapshot.dnsHistoryError,
+                domainPricingError: snapshot.domainPricingError,
+                reputationError: snapshot.reputationError,
+                portScanError: snapshot.portScanError
+            )
         )
 
         history.insert(entry, at: 0)
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index c6ebf4f..258764a 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -1936,78 +1936,92 @@ final class DomainViewModel {
         }
 
         let entry = HistoryEntry(
-            domain: snapshot.domain,
-            timestamp: snapshot.timestamp,
-            trackedDomainID: trackedDomainID,
-            note: currentHistoryEntry?.note,
-            dnsSections: snapshot.dnsSections,
-            sslInfo: snapshot.sslInfo,
-            httpHeaders: snapshot.httpHeaders,
-            reachabilityResults: snapshot.reachabilityResults,
-            ipGeolocation: snapshot.ipGeolocation,
-            emailSecurity: snapshot.emailSecurity,
-            mtaSts: snapshot.emailSecurity?.mtaSts,
-            ownership: snapshot.ownership,
-            ownershipHistory: snapshot.ownershipHistory,
-            inferredProvider: intelligence.inferredProvider,
-            priorProviders: intelligence.priorProviders,
-            domainClassification: intelligence.domainClassification,
-            ownershipTransitions: intelligence.ownershipTransitions,
-            hostingTransitions: intelligence.hostingTransitions,
-            subdomainHistory: intelligence.subdomainHistory,
-            riskSignals: intelligence.riskSignals,
-            intelligenceTimeline: intelligence.timelineEvents,
-            ptrRecord: snapshot.ptrRecord,
-            redirectChain: snapshot.redirectChain,
-            subdomains: snapshot.subdomains,
-            extendedSubdomains: snapshot.extendedSubdomains,
-            dnsHistory: snapshot.dnsHistory,
-            domainPricing: snapshot.domainPricing,
-            reputation: snapshot.reputation,
-            portScanResults: snapshot.portScanResults,
-            hstsPreloaded: snapshot.hstsPreloaded,
-            availabilityResult: snapshot.availabilityResult,
-            suggestions: snapshot.suggestions,
-            appVersion: snapshot.appVersion,
-            resultSource: snapshot.resultSource,
-            dataSources: snapshot.dataSources,
-            provenanceBySection: snapshot.provenanceBySection,
-            availabilityConfidence: snapshot.availabilityConfidence,
-            ownershipConfidence: snapshot.ownershipConfidence,
-            subdomainConfidence: snapshot.subdomainConfidence,
-            emailSecurityConfidence: snapshot.emailSecurityConfidence,
-            geolocationConfidence: snapshot.geolocationConfidence,
-            errorDetails: snapshot.errorDetails,
-            isPartialSnapshot: snapshot.isPartialSnapshot,
-            validationIssues: snapshot.validationIssues,
-            resolverDisplayName: snapshot.resolverDisplayName,
-            resolverURLString: snapshot.resolverURLString,
-            totalLookupDurationMs: snapshot.totalLookupDurationMs,
-            primaryIP: Self.primaryIPAddress(from: snapshot),
-            finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
-            tlsStatusSummary: Self.httpsSummary(from: snapshot),
-            emailSecuritySummary: Self.emailSummary(from: snapshot),
-            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
-            changeSummary: changeSummary,
-            snapshotIndex: nextSnapshotIndex,
-            previousSnapshotID: previousSnapshotID,
-            changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
-            severitySummary: changeSummary?.severity,
-            sslError: snapshot.sslError,
-            httpHeadersError: snapshot.httpHeadersError,
-            reachabilityError: snapshot.reachabilityError,
-            ipGeolocationError: snapshot.ipGeolocationError,
-            emailSecurityError: snapshot.emailSecurityError,
-            ownershipError: snapshot.ownershipError,
-            ownershipHistoryError: snapshot.ownershipHistoryError,
-            ptrError: snapshot.ptrError,
-            redirectChainError: snapshot.redirectChainError,
-            subdomainsError: snapshot.subdomainsError,
-            extendedSubdomainsError: snapshot.extendedSubdomainsError,
-            dnsHistoryError: snapshot.dnsHistoryError,
-            domainPricingError: snapshot.domainPricingError,
-            reputationError: snapshot.reputationError,
-            portScanError: snapshot.portScanError
+            identity: HistoryEntry.Identity(
+                domain: snapshot.domain,
+                timestamp: snapshot.timestamp,
+                trackedDomainID: trackedDomainID,
+                note: currentHistoryEntry?.note
+            ),
+            inspection: HistoryEntry.Inspection(
+                dnsSections: snapshot.dnsSections,
+                sslInfo: snapshot.sslInfo,
+                httpHeaders: snapshot.httpHeaders,
+                reachabilityResults: snapshot.reachabilityResults,
+                ipGeolocation: snapshot.ipGeolocation,
+                emailSecurity: snapshot.emailSecurity,
+                mtaSts: snapshot.emailSecurity?.mtaSts,
+                ptrRecord: snapshot.ptrRecord,
+                redirectChain: snapshot.redirectChain,
+                subdomains: snapshot.subdomains,
+                extendedSubdomains: snapshot.extendedSubdomains,
+                dnsHistory: snapshot.dnsHistory,
+                portScanResults: snapshot.portScanResults,
+                hstsPreloaded: snapshot.hstsPreloaded,
+                availabilityResult: snapshot.availabilityResult,
+                suggestions: snapshot.suggestions
+            ),
+            registration: HistoryEntry.Registration(
+                ownership: snapshot.ownership,
+                ownershipHistory: snapshot.ownershipHistory,
+                inferredProvider: intelligence.inferredProvider,
+                priorProviders: intelligence.priorProviders,
+                domainClassification: intelligence.domainClassification,
+                ownershipTransitions: intelligence.ownershipTransitions,
+                hostingTransitions: intelligence.hostingTransitions,
+                domainPricing: snapshot.domainPricing
+            ),
+            intelligence: HistoryEntry.Intelligence(
+                subdomainHistory: intelligence.subdomainHistory,
+                riskSignals: intelligence.riskSignals,
+                intelligenceTimeline: intelligence.timelineEvents,
+                reputation: snapshot.reputation
+            ),
+            provenance: HistoryEntry.Provenance(
+                appVersion: snapshot.appVersion,
+                resultSource: snapshot.resultSource,
+                dataSources: snapshot.dataSources,
+                provenanceBySection: snapshot.provenanceBySection,
+                availabilityConfidence: snapshot.availabilityConfidence,
+                ownershipConfidence: snapshot.ownershipConfidence,
+                subdomainConfidence: snapshot.subdomainConfidence,
+                emailSecurityConfidence: snapshot.emailSecurityConfidence,
+                geolocationConfidence: snapshot.geolocationConfidence,
+                isPartialSnapshot: snapshot.isPartialSnapshot,
+                validationIssues: snapshot.validationIssues,
+                resolverDisplayName: snapshot.resolverDisplayName,
+                resolverURLString: snapshot.resolverURLString,
+                totalLookupDurationMs: snapshot.totalLookupDurationMs
+            ),
+            summary: HistoryEntry.Summary(
+                primaryIP: Self.primaryIPAddress(from: snapshot),
+                finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
+                tlsStatusSummary: Self.httpsSummary(from: snapshot),
+                emailSecuritySummary: Self.emailSummary(from: snapshot),
+                httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
+                changeSummary: changeSummary,
+                snapshotIndex: nextSnapshotIndex,
+                previousSnapshotID: previousSnapshotID,
+                changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
+                severitySummary: changeSummary?.severity
+            ),
+            failures: HistoryEntry.Failures(
+                errorDetails: snapshot.errorDetails,
+                sslError: snapshot.sslError,
+                httpHeadersError: snapshot.httpHeadersError,
+                reachabilityError: snapshot.reachabilityError,
+                ipGeolocationError: snapshot.ipGeolocationError,
+                emailSecurityError: snapshot.emailSecurityError,
+                ownershipError: snapshot.ownershipError,
+                ownershipHistoryError: snapshot.ownershipHistoryError,
+                ptrError: snapshot.ptrError,
+                redirectChainError: snapshot.redirectChainError,
+                subdomainsError: snapshot.subdomainsError,
+                extendedSubdomainsError: snapshot.extendedSubdomainsError,
+                dnsHistoryError: snapshot.dnsHistoryError,
+                domainPricingError: snapshot.domainPricingError,
+                reputationError: snapshot.reputationError,
+                portScanError: snapshot.portScanError
+            )
         )
 
         if updateCurrentState {
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index e93fb46..c81b8c0 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -2399,113 +2399,196 @@ struct HistoryEntry: Identifiable, Codable {
     var reputationError: String?
     var portScanError: String?
 
-    init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
-         sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
-         reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
-         emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
-         ownershipHistory: [DomainOwnershipHistoryEvent] = [],
-         inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
-         domainClassification: DomainClassificationSummary? = nil,
-         ownershipTransitions: [OwnershipTransitionEvent] = [],
-         hostingTransitions: [HostingTransitionEvent] = [],
-         subdomainHistory: [SubdomainHistoryEntry] = [],
-         riskSignals: [IntelligenceRiskSignal] = [],
-         intelligenceTimeline: [IntelligenceTimelineEvent] = [],
-         ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
-         extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
-         domainPricing: DomainPricingInsight? = nil,
-         reputation: DomainReputationResult? = nil,
-         portScanResults: [PortScanResult] = [],
-         hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
-         suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
-         resultSource: LookupResultSource = .snapshot, dataSources: [String] = [],
-         provenanceBySection: [LookupSectionKind: SectionProvenance] = [:],
-         availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil,
-         subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil,
-         geolocationConfidence: ConfidenceLevel? = nil,
-         errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false,
-         validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
-         totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
-         tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
-         changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
-         changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
-         reachabilityError: String? = nil, ipGeolocationError: String? = nil,
-         emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
-         ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
-         extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
-         domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
-        self.domain = domain
-        self.timestamp = timestamp
-        self.trackedDomainID = trackedDomainID
-        self.note = note
-        self.dnsSections = dnsSections
-        self.sslInfo = sslInfo
-        self.httpHeaders = httpHeaders
-        self.reachabilityResults = reachabilityResults
-        self.ipGeolocation = ipGeolocation
-        self.emailSecurity = emailSecurity
-        self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
-        self.ownership = ownership
-        self.ownershipHistory = ownershipHistory
-        self.inferredProvider = inferredProvider
-        self.priorProviders = priorProviders
-        self.domainClassification = domainClassification
-        self.ownershipTransitions = ownershipTransitions
-        self.hostingTransitions = hostingTransitions
-        self.subdomainHistory = subdomainHistory
-        self.riskSignals = riskSignals
-        self.intelligenceTimeline = intelligenceTimeline
-        self.ptrRecord = ptrRecord
-        self.redirectChain = redirectChain
-        self.subdomains = subdomains
-        self.extendedSubdomains = extendedSubdomains
-        self.dnsHistory = dnsHistory
-        self.domainPricing = domainPricing
-        self.reputation = reputation
-        self.portScanResults = portScanResults
-        self.hstsPreloaded = hstsPreloaded
-        self.availabilityResult = availabilityResult
-        self.suggestions = suggestions
-        self.appVersion = appVersion
-        self.resultSource = resultSource
-        self.dataSources = dataSources
-        self.provenanceBySection = provenanceBySection
-        self.availabilityConfidence = availabilityConfidence
-        self.ownershipConfidence = ownershipConfidence
-        self.subdomainConfidence = subdomainConfidence
-        self.emailSecurityConfidence = emailSecurityConfidence
-        self.geolocationConfidence = geolocationConfidence
-        self.errorDetails = errorDetails
-        self.isPartialSnapshot = isPartialSnapshot
-        self.validationIssues = validationIssues
-        self.resolverDisplayName = resolverDisplayName
-        self.resolverURLString = resolverURLString
-        self.totalLookupDurationMs = totalLookupDurationMs
-        self.primaryIP = primaryIP
-        self.finalRedirectURL = finalRedirectURL
-        self.tlsStatusSummary = tlsStatusSummary
-        self.emailSecuritySummary = emailSecuritySummary
-        self.httpGradeSummary = httpGradeSummary
-        self.changeSummary = changeSummary
-        self.snapshotIndex = snapshotIndex
-        self.previousSnapshotID = previousSnapshotID
-        self.changeCount = changeCount
-        self.severitySummary = severitySummary
-        self.sslError = sslError
-        self.httpHeadersError = httpHeadersError
-        self.reachabilityError = reachabilityError
-        self.ipGeolocationError = ipGeolocationError
-        self.emailSecurityError = emailSecurityError
-        self.ownershipError = ownershipError
-        self.ownershipHistoryError = ownershipHistoryError
-        self.ptrError = ptrError
-        self.redirectChainError = redirectChainError
-        self.subdomainsError = subdomainsError
-        self.extendedSubdomainsError = extendedSubdomainsError
-        self.dnsHistoryError = dnsHistoryError
-        self.domainPricingError = domainPricingError
-        self.reputationError = reputationError
-        self.portScanError = portScanError
+    // A full inspection produces 73 stored properties, and passing them as one
+    // flat argument list made a 72-parameter initializer no call site could read
+    // (SonarCloud swift:S107). They are grouped below by what they describe.
+    //
+    // The stored properties stay flat, deliberately. This type is persisted to
+    // UserDefaults and read back out of backup files, and both decode paths drop
+    // entries that will not parse rather than raising — so nesting a value would
+    // change the encoded shape and silently discard history written by an older
+    // build. These groups exist at the call boundary only; the JSON is unchanged,
+    // which HistoryEntryCodableTests pins.
+
+    /// What was inspected, and when.
+    struct Identity {
+        var domain: String
+        var timestamp: Date
+        var trackedDomainID: UUID? = nil
+        var note: String? = nil
+    }
+
+    /// The live probe results for this snapshot.
+    struct Inspection {
+        var dnsSections: [DNSSection]
+        var sslInfo: SSLCertificateInfo?
+        var httpHeaders: [HTTPHeader]
+        var reachabilityResults: [PortReachability]
+        var ipGeolocation: IPGeolocation?
+        var emailSecurity: EmailSecurityResult? = nil
+        var mtaSts: MTASTSResult? = nil
+        var ptrRecord: String? = nil
+        var redirectChain: [RedirectHop] = []
+        var subdomains: [DiscoveredSubdomain] = []
+        var extendedSubdomains: [DiscoveredSubdomain] = []
+        var dnsHistory: [DNSHistoryEvent] = []
+        var portScanResults: [PortScanResult] = []
+        var hstsPreloaded: Bool? = nil
+        var availabilityResult: DomainAvailabilityResult? = nil
+        var suggestions: [DomainSuggestionResult] = []
+    }
+
+    /// Who owns the domain and where it is hosted.
+    struct Registration {
+        var ownership: DomainOwnership? = nil
+        var ownershipHistory: [DomainOwnershipHistoryEvent] = []
+        var inferredProvider: InferredProviderFingerprint? = nil
+        var priorProviders: [String] = []
+        var domainClassification: DomainClassificationSummary? = nil
+        var ownershipTransitions: [OwnershipTransitionEvent] = []
+        var hostingTransitions: [HostingTransitionEvent] = []
+        var domainPricing: DomainPricingInsight? = nil
+    }
+
+    /// Derived signals rather than direct observations.
+    struct Intelligence {
+        var subdomainHistory: [SubdomainHistoryEntry] = []
+        var riskSignals: [IntelligenceRiskSignal] = []
+        var intelligenceTimeline: [IntelligenceTimelineEvent] = []
+        var reputation: DomainReputationResult? = nil
+    }
+
+    /// Where the data came from, and how much to trust it.
+    struct Provenance {
+        var appVersion: String = "2.7.0"
+        var resultSource: LookupResultSource = .snapshot
+        var dataSources: [String] = []
+        var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
+        var availabilityConfidence: ConfidenceLevel? = nil
+        var ownershipConfidence: ConfidenceLevel? = nil
+        var subdomainConfidence: ConfidenceLevel? = nil
+        var emailSecurityConfidence: ConfidenceLevel? = nil
+        var geolocationConfidence: ConfidenceLevel? = nil
+        var isPartialSnapshot: Bool = false
+        var validationIssues: [String] = []
+        var resolverDisplayName: String
+        var resolverURLString: String
+        var totalLookupDurationMs: Int? = nil
+    }
+
+    /// Precomputed display values and change tracking.
+    struct Summary {
+        var primaryIP: String? = nil
+        var finalRedirectURL: String? = nil
+        var tlsStatusSummary: String? = nil
+        var emailSecuritySummary: String? = nil
+        var httpGradeSummary: String? = nil
+        var changeSummary: DomainChangeSummary? = nil
+        var snapshotIndex: Int? = nil
+        var previousSnapshotID: UUID? = nil
+        var changeCount: Int = 0
+        var severitySummary: ChangeSeverity? = nil
+    }
+
+    /// Per-section failures: a snapshot records what it could not collect.
+    struct Failures {
+        var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
+        var sslError: String? = nil
+        var httpHeadersError: String? = nil
+        var reachabilityError: String? = nil
+        var ipGeolocationError: String? = nil
+        var emailSecurityError: String? = nil
+        var ownershipError: String? = nil
+        var ownershipHistoryError: String? = nil
+        var ptrError: String? = nil
+        var redirectChainError: String? = nil
+        var subdomainsError: String? = nil
+        var extendedSubdomainsError: String? = nil
+        var dnsHistoryError: String? = nil
+        var domainPricingError: String? = nil
+        var reputationError: String? = nil
+        var portScanError: String? = nil
+    }
+
+    init(identity: Identity,
+         inspection: Inspection,
+         registration: Registration = Registration(),
+         intelligence: Intelligence = Intelligence(),
+         provenance: Provenance,
+         summary: Summary = Summary(),
+         failures: Failures = Failures()) {
+        self.domain = identity.domain
+        self.timestamp = identity.timestamp
+        self.trackedDomainID = identity.trackedDomainID
+        self.note = identity.note
+        self.dnsSections = inspection.dnsSections
+        self.sslInfo = inspection.sslInfo
+        self.httpHeaders = inspection.httpHeaders
+        self.reachabilityResults = inspection.reachabilityResults
+        self.ipGeolocation = inspection.ipGeolocation
+        self.emailSecurity = inspection.emailSecurity
+        self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts
+        self.ptrRecord = inspection.ptrRecord
+        self.redirectChain = inspection.redirectChain
+        self.subdomains = inspection.subdomains
+        self.extendedSubdomains = inspection.extendedSubdomains
+        self.dnsHistory = inspection.dnsHistory
+        self.portScanResults = inspection.portScanResults
+        self.hstsPreloaded = inspection.hstsPreloaded
+        self.availabilityResult = inspection.availabilityResult
+        self.suggestions = inspection.suggestions
+        self.ownership = registration.ownership
+        self.ownershipHistory = registration.ownershipHistory
+        self.inferredProvider = registration.inferredProvider
+        self.priorProviders = registration.priorProviders
+        self.domainClassification = registration.domainClassification
+        self.ownershipTransitions = registration.ownershipTransitions
+        self.hostingTransitions = registration.hostingTransitions
+        self.domainPricing = registration.domainPricing
+        self.subdomainHistory = intelligence.subdomainHistory
+        self.riskSignals = intelligence.riskSignals
+        self.intelligenceTimeline = intelligence.intelligenceTimeline
+        self.reputation = intelligence.reputation
+        self.appVersion = provenance.appVersion
+        self.resultSource = provenance.resultSource
+        self.dataSources = provenance.dataSources
+        self.provenanceBySection = provenance.provenanceBySection
+        self.availabilityConfidence = provenance.availabilityConfidence
+        self.ownershipConfidence = provenance.ownershipConfidence
+        self.subdomainConfidence = provenance.subdomainConfidence
+        self.emailSecurityConfidence = provenance.emailSecurityConfidence
+        self.geolocationConfidence = provenance.geolocationConfidence
+        self.isPartialSnapshot = provenance.isPartialSnapshot
+        self.validationIssues = provenance.validationIssues
+        self.resolverDisplayName = provenance.resolverDisplayName
+        self.resolverURLString = provenance.resolverURLString
+        self.totalLookupDurationMs = provenance.totalLookupDurationMs
+        self.primaryIP = summary.primaryIP
+        self.finalRedirectURL = summary.finalRedirectURL
+        self.tlsStatusSummary = summary.tlsStatusSummary
+        self.emailSecuritySummary = summary.emailSecuritySummary
+        self.httpGradeSummary = summary.httpGradeSummary
+        self.changeSummary = summary.changeSummary
+        self.snapshotIndex = summary.snapshotIndex
+        self.previousSnapshotID = summary.previousSnapshotID
+        self.changeCount = summary.changeCount
+        self.severitySummary = summary.severitySummary
+        self.errorDetails = failures.errorDetails
+        self.sslError = failures.sslError
+        self.httpHeadersError = failures.httpHeadersError
+        self.reachabilityError = failures.reachabilityError
+        self.ipGeolocationError = failures.ipGeolocationError
+        self.emailSecurityError = failures.emailSecurityError
+        self.ownershipError = failures.ownershipError
+        self.ownershipHistoryError = failures.ownershipHistoryError
+        self.ptrError = failures.ptrError
+        self.redirectChainError = failures.redirectChainError
+        self.subdomainsError = failures.subdomainsError
+        self.extendedSubdomainsError = failures.extendedSubdomainsError
+        self.dnsHistoryError = failures.dnsHistoryError
+        self.domainPricingError = failures.domainPricingError
+        self.reputationError = failures.reputationError
+        self.portScanError = failures.portScanError
     }
 
     init(from decoder: Decoder) throws {
diff --git a/DomainDigTests/HistoryEntryCodableTests.swift b/DomainDigTests/HistoryEntryCodableTests.swift
new file mode 100644
index 0000000..754c7ef
--- /dev/null
+++ b/DomainDigTests/HistoryEntryCodableTests.swift
@@ -0,0 +1,129 @@
+import XCTest
+@testable import DomainDig
+
+/// Characterization tests pinning `HistoryEntry`'s encoded shape.
+///
+/// This type is not merely an in-memory model: `DomainDataPortabilityService`
+/// persists it to `UserDefaults` as JSON and reads the same shape back out of
+/// backup files. Both decode paths swallow failures — `loadHistoryEntries` uses
+/// `try?` and drops anything that will not decode — so a change to the encoded
+/// keys does not raise, it silently discards a user's saved history.
+///
+/// These tests exist so that any such change fails here first, loudly, instead
+/// of in someone's app.
+final class HistoryEntryCodableTests: XCTestCase {
+    /// Every key `HistoryEntry` is expected to encode. Adding a stored property
+    /// is a format change: extend this list deliberately, and only once you have
+    /// decided what happens to history written by an older build.
+    private static let expectedKeys: Set<String> = [
+        "id", "domain", "timestamp", "trackedDomainID", "note", "dnsSections",
+        "sslInfo", "httpHeaders", "reachabilityResults", "ipGeolocation",
+        "emailSecurity", "mtaSts", "ownership", "ownershipHistory",
+        "inferredProvider", "priorProviders", "domainClassification",
+        "ownershipTransitions", "hostingTransitions", "subdomainHistory",
+        "riskSignals", "intelligenceTimeline", "ptrRecord", "redirectChain",
+        "subdomains", "extendedSubdomains", "dnsHistory", "domainPricing",
+        "reputation", "portScanResults", "hstsPreloaded", "availabilityResult",
+        "suggestions", "appVersion", "resultSource", "dataSources",
+        "provenanceBySection", "availabilityConfidence", "ownershipConfidence",
+        "subdomainConfidence", "emailSecurityConfidence", "geolocationConfidence",
+        "errorDetails", "isPartialSnapshot", "validationIssues",
+        "resolverDisplayName", "resolverURLString", "totalLookupDurationMs",
+        "primaryIP", "finalRedirectURL", "tlsStatusSummary",
+        "emailSecuritySummary", "httpGradeSummary", "changeSummary",
+        "snapshotIndex", "previousSnapshotID", "changeCount", "severitySummary",
+        "sslError", "httpHeadersError", "reachabilityError", "ipGeolocationError",
+        "emailSecurityError", "ownershipError", "ownershipHistoryError",
+        "ptrError", "redirectChainError", "subdomainsError",
+        "extendedSubdomainsError", "dnsHistoryError", "domainPricingError",
+        "reputationError", "portScanError",
+    ]
+
+    private func makeEntry() -> HistoryEntry {
+        HistoryEntry(
+            identity: .init(
+                domain: "example.com",
+                timestamp: Date(timeIntervalSince1970: 1_700_000_000)
+            ),
+            inspection: .init(
+                dnsSections: [],
+                sslInfo: nil,
+                httpHeaders: [],
+                reachabilityResults: [],
+                ipGeolocation: nil
+            ),
+            provenance: .init(
+                resolverDisplayName: "Test Resolver",
+                resolverURLString: "https://resolver.example/dns-query"
+            )
+        )
+    }
+
+    private func encoder() -> JSONEncoder {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = .sortedKeys
+        return encoder
+    }
+
+    /// The set of top-level keys is the persisted contract. Optionals that are
+    /// nil are omitted by the synthesized encoder, so this asserts containment
+    /// rather than equality — no key may appear that is not accounted for.
+    func testEncodedKeysAreAllAccountedFor() throws {
+        let data = try encoder().encode(makeEntry())
+        let object = try XCTUnwrap(
+            JSONSerialization.jsonObject(with: data) as? [String: Any]
+        )
+
+        let unexpected = Set(object.keys).subtracting(Self.expectedKeys)
+        XCTAssertTrue(
+            unexpected.isEmpty,
+            "HistoryEntry encoded keys not in the pinned set: \(unexpected.sorted()). "
+                + "This changes the persisted format; older history will not decode."
+        )
+    }
+
+    /// A populated entry must survive encode → decode → encode unchanged. This
+    /// is the guard that a refactor which regroups the initializer has not also
+    /// moved a value into a different place in the JSON.
+    func testRoundTripIsStable() throws {
+        let first = try encoder().encode(makeEntry())
+        let decoded = try JSONDecoder().decode(HistoryEntry.self, from: first)
+        let second = try encoder().encode(decoded)
+
+        XCTAssertEqual(
+            first, second,
+            "HistoryEntry did not survive a JSON round trip unchanged"
+        )
+    }
+
+    /// The values a caller supplies must land under the keys the persisted format
+    /// already uses, not merely somewhere in the document.
+    func testRequiredValuesEncodeAtTheTopLevel() throws {
+        let data = try encoder().encode(makeEntry())
+        let object = try XCTUnwrap(
+            JSONSerialization.jsonObject(with: data) as? [String: Any]
+        )
+
+        XCTAssertEqual(object["domain"] as? String, "example.com")
+        XCTAssertEqual(object["resolverDisplayName"] as? String, "Test Resolver")
+        XCTAssertEqual(
+            object["resolverURLString"] as? String,
+            "https://resolver.example/dns-query"
+        )
+    }
+
+    /// What `loadHistoryEntries` actually does with a stored blob, end to end:
+    /// anything that fails to decode is dropped without error, so this pins that
+    /// a current-format entry survives the real read path.
+    func testSurvivesTheRealPersistencePath() throws {
+        let suite = "DomainDigTests.historyEntryCodable"
+        let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        DomainDataPortabilityService.saveHistoryEntries([makeEntry()], defaults: defaults)
+        let loaded = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
+
+        XCTAssertEqual(loaded.count, 1, "entry was silently dropped by the load path")
+        XCTAssertEqual(loaded.first?.domain, "example.com")
+    }
+}