krz/domain-dig

an ios app for DNS & SSL analysis

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

37b02e5ee360d61751951b77b7b556165a3af1fc

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T16:46:52Z

feat(v3.0.0): unify platform architecture and introduce feature tiers

* consolidate DomainReport as canonical data model
* add feature tier system (free/pro/data+ scaffolding)
* apply clean feature gating across workflows and tracking
* refactor app structure for maintainability
* standardize navigation and settings
* add data lifecycle controls
* ensure consistency across UI, export, and CLI
* stabilize internal inspection API
 Docs/ARCHITECTURE.md                 |  48 ++++++++
 DomainDig/AppVersion.swift           |   2 +-
 DomainDig/ContentView.swift          | 225 +++++++++++++++++++++++------------
 DomainDig/DataAccessService.swift    |  11 +-
 DomainDig/DomainDigApp.swift         |   3 +-
 DomainDig/DomainViewModel.swift      |  48 +++++++-
 DomainDig/FeatureAccessService.swift | 160 +++++++++++++++++++++++++
 DomainDig/PremiumAccessService.swift |  15 ++-
 DomainDig/RootTabView.swift          |  42 +++++++
 DomainDig/WatchlistView.swift        |  19 ++-
 DomainDig/WorkflowsView.swift        | 159 +++++++++++++++----------
 DomainReportBuilder.swift            |  64 +++++++++-
 DomainReportExporter.swift           |  35 ++++--
 13 files changed, 654 insertions(+), 177 deletions(-)

diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md
new file mode 100644
index 0000000..0d364d6
--- /dev/null
+++ b/Docs/ARCHITECTURE.md
@@ -0,0 +1,48 @@
+# DomainDig v3.0.0 Architecture
+
+## Overview
+
+DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`.
+
+Inspection flow:
+
+1. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`.
+2. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`.
+3. UI, exports, and CLI rendering derive from `DomainReport`.
+
+`LookupSnapshot` remains an internal collection and persistence shape. `DomainReport` is the stable presentation and export contract.
+
+## Canonical Report Lifecycle
+
+- `LookupRuntime` coordinates section services.
+- `DomainInspectionService` normalizes failures, provenance, cache state, and section metadata.
+- `DomainReportBuilder` adds summaries, insights, risk scoring, change analysis, workflow context, and report metadata.
+- `DomainReportExporter` renders TXT, CSV, and JSON from the same report payload.
+- `DomainDigCLI` prints exporter output directly so CLI output matches the app.
+
+## Feature Tiers
+
+The app now uses `FeatureAccessService` as the single feature gating surface.
+
+- `Free`: single lookup, basic history, limited tracking
+- `Pro`: workflows, batch operations, advanced exports
+- `Data+`: future historical datasets and extended enrichment
+
+Current release behavior is static scaffolding only. There are no purchases, backend checks, or remote entitlements.
+
+## Data Boundaries
+
+- Inspection services: network collection only
+- `DomainReportBuilder`: canonical model assembly
+- `FeatureAccessService`: tier and capability checks
+- `DomainViewModel`: UI orchestration, persistence, batch coordination
+- Views: rendering and interaction only
+
+## Adding a New Data Source
+
+1. Add the raw collection call to `LookupRuntime`.
+2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures.
+3. Extend `LookupSnapshot` only if the raw result must persist.
+4. Add the summarized representation to `DomainReportBuilder`.
+5. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI.
+6. Render the new summary in SwiftUI using `DomainReport` fields.
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index 9342324..1a6861a 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     static var current: String {
-        "2.9.0"
+        "3.0.0"
     }
 }
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 79ecf64..9802f77 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -24,7 +24,7 @@ private struct WorkflowNavigationTarget: Hashable {
 
 struct ContentView: View {
     @Environment(\.appDensity) private var appDensity
-    @State private var viewModel = DomainViewModel()
+    @Bindable var viewModel: DomainViewModel
     @State private var navigationPath = NavigationPath()
     @FocusState private var domainFieldFocused: Bool
     @State private var customPortInput = ""
@@ -32,6 +32,7 @@ struct ContentView: View {
     @State private var trackingNoteDraft = ""
     @State private var editingTrackedDomain: TrackedDomain?
     @State private var showTrackLimitAlert = false
+    @State private var featureGateMessage: String?
     @State private var inputMode: LookupInputMode = .single
     @State private var collapsedSections: Set<ResultSection> = [.network]
     @State private var showingCurrentDomainWorkflowSheet = false
@@ -222,7 +223,7 @@ struct ContentView: View {
             .toolbarColorScheme(.dark, for: .navigationBar)
             .preferredColorScheme(.dark)
             .toolbar {
-                ToolbarItemGroup(placement: .topBarTrailing) {
+                ToolbarItem(placement: .topBarTrailing) {
                     if viewModel.hasRun {
                         Button {
                             viewModel.reset()
@@ -231,40 +232,6 @@ struct ContentView: View {
                                 .foregroundStyle(.secondary)
                         }
                     }
-                    NavigationLink {
-                        WatchlistView(viewModel: viewModel)
-                    } label: {
-                        Image(systemName: "eye")
-                            .foregroundStyle(.secondary)
-                    }
-                    Menu {
-                        NavigationLink {
-                            HistoryView(viewModel: viewModel)
-                        } label: {
-                            Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
-                        }
-
-                        NavigationLink {
-                            SavedDomainsView(viewModel: viewModel)
-                        } label: {
-                            Label("Saved Domains", systemImage: "bookmark")
-                        }
-
-                        NavigationLink {
-                            WorkflowsView(viewModel: viewModel)
-                        } label: {
-                            Label("Workflows", systemImage: "square.stack.3d.down.right")
-                        }
-
-                        NavigationLink {
-                            SettingsView(viewModel: viewModel)
-                        } label: {
-                            Label("Settings", systemImage: "gearshape")
-                        }
-                    } label: {
-                        Image(systemName: "ellipsis.circle")
-                            .foregroundStyle(.secondary)
-                    }
                 }
             }
             .navigationDestination(for: WorkflowNavigationTarget.self) { target in
@@ -284,7 +251,19 @@ struct ContentView: View {
         .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
             Button("OK", role: .cancel) {}
         } message: {
-            Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
+            Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.")
+        }
+        .alert("Feature unavailable", isPresented: Binding(
+            get: { featureGateMessage != nil },
+            set: { newValue in
+                if !newValue {
+                    featureGateMessage = nil
+                }
+            }
+        )) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(featureGateMessage ?? "")
         }
         .sheet(item: $editingTrackedDomain) { trackedDomain in
             NavigationStack {
@@ -361,37 +340,44 @@ struct ContentView: View {
                 .buttonStyle(.borderedProminent)
                 .disabled(viewModel.trimmedDomain.isEmpty)
             } else {
-                VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
-                    Text("Paste domains separated by new lines or commas.")
-                        .font(appDensity.font(.caption))
-                        .foregroundStyle(.secondary)
+                if FeatureAccessService.hasAccess(to: .batchOperations) {
+                    VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
+                        Text("Paste domains separated by new lines or commas.")
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
 
-                    TextField(
-                        "example.com\napple.com, openai.com",
-                        text: $viewModel.bulkInput,
-                        axis: .vertical
-                    )
-                    .font(appDensity.font(.body))
-                    .textInputAutocapitalization(.never)
-                    .autocorrectionDisabled()
-                    .keyboardType(.URL)
-                    .lineLimit(4...10)
-                    .padding(.horizontal, 12)
-                    .padding(.vertical, appDensity.metrics.controlVerticalPadding)
-                    .background(Color(.systemGray6))
-                    .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
+                        TextField(
+                            "example.com\napple.com, openai.com",
+                            text: $viewModel.bulkInput,
+                            axis: .vertical
+                        )
+                        .font(appDensity.font(.body))
+                        .textInputAutocapitalization(.never)
+                        .autocorrectionDisabled()
+                        .keyboardType(.URL)
+                        .lineLimit(4...10)
+                        .padding(.horizontal, 12)
+                        .padding(.vertical, appDensity.metrics.controlVerticalPadding)
+                        .background(Color(.systemGray6))
+                        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
 
-                    Button {
-                        domainFieldFocused = false
-                        viewModel.runBulkLookup()
-                    } label: {
-                        Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
-                            .font(appDensity.font(.headline, design: .default, weight: .semibold))
-                            .frame(maxWidth: .infinity)
-                            .frame(minHeight: appDensity.metrics.controlMinHeight)
+                        Button {
+                            domainFieldFocused = false
+                            viewModel.runBulkLookup()
+                        } label: {
+                            Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
+                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
+                                .frame(maxWidth: .infinity)
+                                .frame(minHeight: appDensity.metrics.controlMinHeight)
+                        }
+                        .buttonStyle(.borderedProminent)
+                        .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
                     }
-                    .buttonStyle(.borderedProminent)
-                    .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
+                } else {
+                    lockedFeatureCard(
+                        title: "Batch Operations",
+                        message: FeatureAccessService.upgradeMessage(for: .batchOperations)
+                    )
                 }
             }
         }
@@ -411,7 +397,11 @@ struct ContentView: View {
                         }
                     }
                     Button("Add to workflow") {
-                        showingCurrentDomainWorkflowSheet = true
+                        if FeatureAccessService.hasAccess(to: .workflows) {
+                            showingCurrentDomainWorkflowSheet = true
+                        } else {
+                            featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
+                        }
                     }
                     Button("Copy report JSON") {
                         guard let json = viewModel.exportJSONString() else { return }
@@ -437,11 +427,18 @@ struct ContentView: View {
                     Button("Export TXT") {
                         shareSingleResults(format: .text)
                     }
-                    Button("Export CSV") {
-                        shareSingleResults(format: .csv)
-                    }
-                    Button("Export JSON") {
-                        shareSingleResults(format: .json)
+                    if FeatureAccessService.hasAccess(to: .advancedExports) {
+                        Button("Export CSV") {
+                            shareSingleResults(format: .csv)
+                        }
+                        Button("Export JSON") {
+                            shareSingleResults(format: .json)
+                        }
+                    } else {
+                        Button("CSV Export • Available in Pro") {}
+                            .disabled(true)
+                        Button("JSON Export • Available in Pro") {}
+                            .disabled(true)
                     }
                 } label: {
                     Image(systemName: "square.and.arrow.up")
@@ -466,17 +463,28 @@ struct ContentView: View {
                 if !viewModel.currentBatchResultEntries.isEmpty {
                     Menu {
                         Button("Add to Workflow") {
-                            showingBatchWorkflowSheet = true
+                            if FeatureAccessService.hasAccess(to: .workflows) {
+                                showingBatchWorkflowSheet = true
+                            } else {
+                                featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
+                            }
                         }
                         Divider()
                         Button("Export Batch TXT") {
                             shareBatchResults(format: .text)
                         }
-                        Button("Export Batch CSV") {
-                            shareBatchResults(format: .csv)
-                        }
-                        Button("Export Batch JSON") {
-                            shareBatchResults(format: .json)
+                        if FeatureAccessService.hasAccess(to: .advancedExports) {
+                            Button("Export Batch CSV") {
+                                shareBatchResults(format: .csv)
+                            }
+                            Button("Export Batch JSON") {
+                                shareBatchResults(format: .json)
+                            }
+                        } else {
+                            Button("Batch CSV • Available in Pro") {}
+                                .disabled(true)
+                            Button("Batch JSON • Available in Pro") {}
+                                .disabled(true)
                         }
                     } label: {
                         Label("Export", systemImage: "square.and.arrow.up")
@@ -534,6 +542,20 @@ struct ContentView: View {
         }
     }
 
+    private func lockedFeatureCard(title: String, message: String) -> some View {
+        VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
+            Text(title)
+                .font(appDensity.font(.headline, design: .default, weight: .semibold))
+            Text(message)
+                .font(appDensity.font(.callout, design: .default))
+                .foregroundStyle(.secondary)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(appDensity.metrics.cardPadding)
+        .background(Color(.systemGray6))
+        .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
+    }
+
     private func parsedCustomPorts(from input: String) -> [UInt16] {
         let parts = input.split(separator: ",", omittingEmptySubsequences: true)
         var seen = Set<UInt16>()
@@ -2140,7 +2162,7 @@ private extension String {
     }
 }
 
-private struct SettingsView: View {
+struct SettingsView: View {
     @Environment(\.appDensity) private var appDensity
     @Bindable var viewModel: DomainViewModel
     @AppStorage(DNSResolverOption.userDefaultsKey)
@@ -2152,6 +2174,8 @@ private struct SettingsView: View {
     @State private var customResolverURL = DNSResolverOption.defaultURLString
     @State private var showClearHistoryConfirmation = false
     @State private var showClearCacheConfirmation = false
+    @State private var showClearWorkflowsConfirmation = false
+    @State private var showClearTrackedDomainsConfirmation = false
 
     private var customResolverError: String? {
         guard resolverOption == .custom else {
@@ -2199,11 +2223,38 @@ private struct SettingsView: View {
                 Button("Clear Cache", role: .destructive) {
                     showClearCacheConfirmation = true
                 }
+
+                Button("Clear Workflows", role: .destructive) {
+                    showClearWorkflowsConfirmation = true
+                }
+
+                Button("Clear Tracked Domains", role: .destructive) {
+                    showClearTrackedDomainsConfirmation = true
+                }
+            }
+
+            Section("Features") {
+                LabeledContent("Tier", value: FeatureAccessService.currentTier.title)
+
+                if FeatureAccessService.enabledFeatureLabels().isEmpty {
+                    Text("No features enabled.")
+                        .font(appDensity.font(.caption, design: .default))
+                        .foregroundStyle(.secondary)
+                } else {
+                    ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
+                        Text(label)
+                    }
+                }
+
+                Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.")
+                    .font(appDensity.font(.caption, design: .default))
+                    .foregroundStyle(.secondary)
             }
 
             Section("About") {
                 LabeledContent("Version", value: appVersion)
                 LabeledContent("Storage", value: "Local-only")
+                LabeledContent("Report Schema", value: "3.0.0")
             }
         }
         .navigationTitle("Settings")
@@ -2223,6 +2274,22 @@ private struct SettingsView: View {
         } message: {
             Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
         }
+        .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
+            Button("Clear", role: .destructive) {
+                viewModel.clearWorkflows()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
+        }
+        .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
+            Button("Clear", role: .destructive) {
+                viewModel.clearTrackedDomains()
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text("This removes the watchlist only. History and workflows stay intact.")
+        }
         .onAppear {
             let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
             resolverOption = DNSResolverOption.option(for: currentResolverURL)
@@ -2247,5 +2314,5 @@ private struct SettingsView: View {
 }
 
 #Preview {
-    ContentView()
+    ContentView(viewModel: DomainViewModel())
 }
diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift
index fed4fde..d0254ac 100644
--- a/DomainDig/DataAccessService.swift
+++ b/DomainDig/DataAccessService.swift
@@ -2,6 +2,15 @@ import Foundation
 
 enum DataAccessService {
     static func hasAccess(to capability: DataCapability) -> Bool {
-        false
+        switch capability {
+        case .ownershipHistory:
+            return FeatureAccessService.hasAccess(to: .ownershipHistory)
+        case .dnsHistory:
+            return FeatureAccessService.hasAccess(to: .dnsHistory)
+        case .extendedSubdomains:
+            return FeatureAccessService.hasAccess(to: .extendedSubdomains)
+        case .domainPricing:
+            return false
+        }
     }
 }
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 29e0117..5eeed32 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -10,6 +10,7 @@ import SwiftUI
 @main
 struct DomainDigApp: App {
     @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
+    @State private var viewModel = DomainViewModel()
 
     init() {
         LocalNotificationService.shared.configureForegroundPresentation()
@@ -17,7 +18,7 @@ struct DomainDigApp: App {
 
     var body: some Scene {
         WindowGroup {
-            ContentView()
+            RootTabView(viewModel: viewModel)
                 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
         }
     }
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 18ce4c2..5288eaa 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -369,11 +369,11 @@ final class DomainViewModel {
     }
 
     var trackingLimitMessage: String? {
-        nil
+        FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count)
     }
 
     var canTrackCurrentDomain: Bool {
-        true
+        FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
     }
 
     var resolverDisplayName: String {
@@ -665,6 +665,18 @@ final class DomainViewModel {
         }
     }
 
+    func clearWorkflows() {
+        workflows.removeAll()
+        latestWorkflowRunSummary = nil
+        persistWorkflows()
+    }
+
+    func clearTrackedDomains() {
+        trackedDomains.removeAll()
+        refreshingTrackedDomainID = nil
+        persistTrackedDomains()
+    }
+
     func clearRecentSearches() {
         recentSearches.removeAll()
         UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -725,10 +737,12 @@ final class DomainViewModel {
     func runBulkLookup() {
         let domains = parsedDomains(from: bulkInput)
         guard !domains.isEmpty else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
         startBatchLookup(domains: domains, source: .manual)
     }
 
     func refreshAllTrackedDomains() {
+        guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
         startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
     }
 
@@ -777,6 +791,7 @@ final class DomainViewModel {
         let normalizedDomains = normalizedDomains(domains)
         let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
+        guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
 
         let workflow = DomainWorkflow(
             name: trimmedName,
@@ -840,6 +855,7 @@ final class DomainViewModel {
 
     func runWorkflow(_ workflow: DomainWorkflow) {
         guard !workflow.domains.isEmpty else { return }
+        guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
         startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
     }
 
@@ -2124,14 +2140,23 @@ final class DomainViewModel {
     }
 
     private func currentBatchReports() -> [DomainReport] {
-        currentBatchResultEntries.map(report(for:))
+        currentBatchResultEntries.map { entry in
+            report(for: entry, workflowContext: activeWorkflowContext)
+        }
     }
 
     private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
         let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
         return filteredResults.compactMap { result in
             guard let entry = historyEntry(for: result) else { return nil }
-            return report(for: entry)
+            return report(
+                for: entry,
+                workflowContext: DomainWorkflowContext(
+                    workflowID: summary.workflowID,
+                    workflowName: summary.workflowName,
+                    source: "workflow"
+                )
+            )
         }
     }
 
@@ -2150,8 +2175,19 @@ final class DomainViewModel {
         }
     }
 
-    private func report(for entry: HistoryEntry) -> DomainReport {
-        reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry))
+    private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
+        reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
+    }
+
+    private var activeWorkflowContext: DomainWorkflowContext? {
+        guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
+            return nil
+        }
+        return DomainWorkflowContext(
+            workflowID: activeWorkflowRunID,
+            workflowName: activeWorkflowRunName,
+            source: "workflow"
+        )
     }
 
     private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
new file mode 100644
index 0000000..5f380bb
--- /dev/null
+++ b/DomainDig/FeatureAccessService.swift
@@ -0,0 +1,160 @@
+import Foundation
+
+enum FeatureTier: String, Codable, CaseIterable, Identifiable {
+    case free
+    case pro
+    case dataPlus
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .free:
+            return "Free"
+        case .pro:
+            return "Pro"
+        case .dataPlus:
+            return "Data+"
+        }
+    }
+}
+
+enum FeatureCapability: String, CaseIterable, Identifiable {
+    case singleLookup
+    case basicHistory
+    case limitedTracking
+    case workflows
+    case batchOperations
+    case advancedExports
+    case ownershipHistory
+    case dnsHistory
+    case extendedSubdomains
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .singleLookup:
+            return "Single lookup"
+        case .basicHistory:
+            return "Basic history"
+        case .limitedTracking:
+            return "Limited tracking"
+        case .workflows:
+            return "Workflows"
+        case .batchOperations:
+            return "Batch operations"
+        case .advancedExports:
+            return "Advanced exports"
+        case .ownershipHistory:
+            return "Ownership history"
+        case .dnsHistory:
+            return "DNS history"
+        case .extendedSubdomains:
+            return "Extended subdomains"
+        }
+    }
+}
+
+struct FeatureEntitlements: Equatable {
+    let tier: FeatureTier
+    let capabilities: Set<FeatureCapability>
+    let trackedDomainLimit: Int
+    let workflowLimit: Int?
+    let batchSizeLimit: Int?
+}
+
+enum FeatureAccessService {
+    static let currentTier: FeatureTier = .free
+
+    static var entitlements: FeatureEntitlements {
+        switch currentTier {
+        case .free:
+            return FeatureEntitlements(
+                tier: .free,
+                capabilities: [.singleLookup, .basicHistory, .limitedTracking],
+                trackedDomainLimit: 3,
+                workflowLimit: 0,
+                batchSizeLimit: 0
+            )
+        case .pro:
+            return FeatureEntitlements(
+                tier: .pro,
+                capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
+                trackedDomainLimit: 250,
+                workflowLimit: 50,
+                batchSizeLimit: 100
+            )
+        case .dataPlus:
+            return FeatureEntitlements(
+                tier: .dataPlus,
+                capabilities: Set(FeatureCapability.allCases),
+                trackedDomainLimit: 1_000,
+                workflowLimit: 200,
+                batchSizeLimit: 250
+            )
+        }
+    }
+
+    static func hasAccess(to capability: FeatureCapability) -> Bool {
+        entitlements.capabilities.contains(capability)
+    }
+
+    static func canAddTrackedDomain(currentCount: Int) -> Bool {
+        currentCount < entitlements.trackedDomainLimit
+    }
+
+    static func trackedDomainLimitMessage(currentCount: Int) -> String? {
+        guard currentTier == .free else { return nil }
+        return currentCount >= entitlements.trackedDomainLimit
+            ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
+            : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
+    }
+
+    static func canCreateWorkflow(currentCount: Int) -> Bool {
+        guard hasAccess(to: .workflows) else { return false }
+        guard let limit = entitlements.workflowLimit else { return true }
+        return currentCount < limit
+    }
+
+    static func canRunBatch(domainCount: Int) -> Bool {
+        guard hasAccess(to: .batchOperations) else { return false }
+        guard let limit = entitlements.batchSizeLimit else { return true }
+        return domainCount <= limit
+    }
+
+    static func upgradeMessage(for capability: FeatureCapability) -> String {
+        switch capability {
+        case .workflows, .batchOperations, .advancedExports:
+            return "Available in Pro"
+        case .ownershipHistory, .dnsHistory, .extendedSubdomains:
+            return "Available in Data+"
+        case .limitedTracking:
+            return "Tracking is limited on Free"
+        case .singleLookup, .basicHistory:
+            return "Included in Free"
+        }
+    }
+
+    static func workflowLimitMessage(currentCount: Int) -> String? {
+        guard hasAccess(to: .workflows) else {
+            return upgradeMessage(for: .workflows)
+        }
+        guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
+        return "Workflow limit reached."
+    }
+
+    static func batchLimitMessage(domainCount: Int) -> String? {
+        guard hasAccess(to: .batchOperations) else {
+            return upgradeMessage(for: .batchOperations)
+        }
+        guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
+        return "Batch limit is \(limit) domains on \(currentTier.title)."
+    }
+
+    static func enabledFeatureLabels() -> [String] {
+        FeatureCapability.allCases
+            .filter { hasAccess(to: $0) }
+            .map(\.title)
+    }
+}
diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift
index 0987dae..8de2626 100644
--- a/DomainDig/PremiumAccessService.swift
+++ b/DomainDig/PremiumAccessService.swift
@@ -2,14 +2,23 @@ import Foundation
 
 enum PremiumAccessService {
     static func hasAccess(to capability: PremiumCapability) -> Bool {
-        true
+        switch capability {
+        case .advancedExports:
+            return FeatureAccessService.hasAccess(to: .advancedExports)
+        case .batchTracking:
+            return FeatureAccessService.hasAccess(to: .batchOperations)
+        case .unlimitedTrackedDomains:
+            return FeatureAccessService.currentTier != .free
+        case .automatedMonitoring, .pushAlerts:
+            return false
+        }
     }
 
     static func trackedDomainLimitMessage(currentCount: Int) -> String? {
-        nil
+        FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount)
     }
 
     static func canAddTrackedDomain(currentCount: Int) -> Bool {
-        true
+        FeatureAccessService.canAddTrackedDomain(currentCount: currentCount)
     }
 }
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
new file mode 100644
index 0000000..19ea444
--- /dev/null
+++ b/DomainDig/RootTabView.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct RootTabView: View {
+    @Bindable var viewModel: DomainViewModel
+
+    var body: some View {
+        TabView {
+            ContentView(viewModel: viewModel)
+                .tabItem {
+                    Label("Inspect", systemImage: "magnifyingglass")
+                }
+
+            NavigationStack {
+                WatchlistView(viewModel: viewModel)
+            }
+            .tabItem {
+                Label("Watchlist", systemImage: "eye")
+            }
+
+            NavigationStack {
+                HistoryView(viewModel: viewModel)
+            }
+            .tabItem {
+                Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+            }
+
+            NavigationStack {
+                WorkflowsView(viewModel: viewModel)
+            }
+            .tabItem {
+                Label("Workflows", systemImage: "square.stack.3d.down.right")
+            }
+
+            NavigationStack {
+                SettingsView(viewModel: viewModel)
+            }
+            .tabItem {
+                Label("Settings", systemImage: "gearshape")
+            }
+        }
+    }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index a434705..fcb5a16 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -55,7 +55,7 @@ struct WatchlistView: View {
                 }
                 .listRowBackground(Color(.systemGray6).opacity(0.5))
             } else {
-                if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
+                if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
                     Section {
                         Text(limitMessage)
                             .font(appDensity.font(.caption))
@@ -108,12 +108,19 @@ struct WatchlistView: View {
                             shareTrackedDomains(format: .text)
                         }
 
-                        Button("Export CSV") {
-                            shareTrackedDomains(format: .csv)
-                        }
+                        if FeatureAccessService.hasAccess(to: .advancedExports) {
+                            Button("Export CSV") {
+                                shareTrackedDomains(format: .csv)
+                            }
 
-                        Button("Export JSON") {
-                            shareTrackedDomains(format: .json)
+                            Button("Export JSON") {
+                                shareTrackedDomains(format: .json)
+                            }
+                        } else {
+                            Button("CSV Export • Available in Pro") {}
+                                .disabled(true)
+                            Button("JSON Export • Available in Pro") {}
+                                .disabled(true)
                         }
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index b870409..9d99b02 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -15,57 +15,18 @@ struct WorkflowsView: View {
 
     var body: some View {
         List {
-            if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
-                Section("Workflow Run") {
-                    VStack(alignment: .leading, spacing: 8) {
-                        ProgressView(
-                            value: Double(viewModel.batchCompletedCount),
-                            total: Double(max(viewModel.batchTotalCount, 1))
-                        )
-                        .tint(.cyan)
-
-                        HStack {
-                            Text(viewModel.batchProgressLabel)
-                                .font(appDensity.font(.caption))
-                                .foregroundStyle(.secondary)
-                            Spacer()
-                            if viewModel.batchLookupRunning {
-                                Button("Cancel") {
-                                    viewModel.cancelBatchLookup()
-                                }
-                                .buttonStyle(.bordered)
-                                .font(appDensity.font(.caption2))
-                            }
-                        }
-                    }
-                    .padding(.vertical, 4)
-                }
-                .listRowBackground(Color(.systemGray6).opacity(0.5))
-            }
-
-            if viewModel.workflows.isEmpty {
+            if FeatureAccessService.hasAccess(to: .workflows) {
+                workflowContent
+            } else {
                 Section {
                     EmptyStateCardView(
-                        title: "No Workflows Yet",
-                        message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
-                        suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+                        title: "Workflows",
+                        message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
+                        suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
                         systemImage: "square.stack.3d.down.right"
                     )
                 }
                 .listRowBackground(Color(.systemGray6).opacity(0.5))
-            } else {
-                ForEach(viewModel.workflows) { workflow in
-                    NavigationLink {
-                        WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
-                    } label: {
-                        WorkflowRowView(workflow: workflow)
-                    }
-                    .listRowBackground(Color(.systemGray6).opacity(0.5))
-                }
-                .onDelete { offsets in
-                    let workflows = offsets.map { viewModel.workflows[$0] }
-                    workflows.forEach(viewModel.deleteWorkflow)
-                }
             }
         }
         .scrollContentBackground(.hidden)
@@ -75,15 +36,17 @@ struct WorkflowsView: View {
         }
         .navigationTitle("Workflows")
         .toolbar {
-            ToolbarItemGroup(placement: .topBarTrailing) {
-                Button {
-                    showingCreateWorkflow = true
-                } label: {
-                    Image(systemName: "plus.circle")
-                }
+            if FeatureAccessService.hasAccess(to: .workflows) {
+                ToolbarItemGroup(placement: .topBarTrailing) {
+                    Button {
+                        showingCreateWorkflow = true
+                    } label: {
+                        Image(systemName: "plus.circle")
+                    }
 
-                if !viewModel.workflows.isEmpty {
-                    EditButton()
+                    if !viewModel.workflows.isEmpty {
+                        EditButton()
+                    }
                 }
             }
         }
@@ -102,6 +65,62 @@ struct WorkflowsView: View {
             set: { viewModel.latestWorkflowRunSummary = $0 }
         )
     }
+
+    @ViewBuilder
+    private var workflowContent: some View {
+        if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
+            Section("Workflow Run") {
+                VStack(alignment: .leading, spacing: 8) {
+                    ProgressView(
+                        value: Double(viewModel.batchCompletedCount),
+                        total: Double(max(viewModel.batchTotalCount, 1))
+                    )
+                    .tint(.cyan)
+
+                    HStack {
+                        Text(viewModel.batchProgressLabel)
+                            .font(appDensity.font(.caption))
+                            .foregroundStyle(.secondary)
+                        Spacer()
+                        if viewModel.batchLookupRunning {
+                            Button("Cancel") {
+                                viewModel.cancelBatchLookup()
+                            }
+                            .buttonStyle(.bordered)
+                            .font(appDensity.font(.caption2))
+                        }
+                    }
+                }
+                .padding(.vertical, 4)
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+        }
+
+        if viewModel.workflows.isEmpty {
+            Section {
+                EmptyStateCardView(
+                    title: "No Workflows Yet",
+                    message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
+                    suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+                    systemImage: "square.stack.3d.down.right"
+                )
+            }
+            .listRowBackground(Color(.systemGray6).opacity(0.5))
+        } else {
+            ForEach(viewModel.workflows) { workflow in
+                NavigationLink {
+                    WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
+                } label: {
+                    WorkflowRowView(workflow: workflow)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+            .onDelete { offsets in
+                let workflows = offsets.map { viewModel.workflows[$0] }
+                workflows.forEach(viewModel.deleteWorkflow)
+            }
+        }
+    }
 }
 
 private struct WorkflowRowView: View {
@@ -197,11 +216,18 @@ struct WorkflowDetailView: View {
                                 Button("Export TXT") {
                                     shareWorkflowResults(format: .text)
                                 }
-                                Button("Export CSV") {
-                                    shareWorkflowResults(format: .csv)
-                                }
-                                Button("Export JSON") {
-                                    shareWorkflowResults(format: .json)
+                                if FeatureAccessService.hasAccess(to: .advancedExports) {
+                                    Button("Export CSV") {
+                                        shareWorkflowResults(format: .csv)
+                                    }
+                                    Button("Export JSON") {
+                                        shareWorkflowResults(format: .json)
+                                    }
+                                } else {
+                                    Button("CSV Export • Available in Pro") {}
+                                        .disabled(true)
+                                    Button("JSON Export • Available in Pro") {}
+                                        .disabled(true)
                                 }
                             } label: {
                                 Label("Export Results", systemImage: "square.and.arrow.up")
@@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View {
                         Button("Export TXT") {
                             share(format: .text)
                         }
-                        Button("Export CSV") {
-                            share(format: .csv)
-                        }
-                        Button("Export JSON") {
-                            share(format: .json)
+                        if FeatureAccessService.hasAccess(to: .advancedExports) {
+                            Button("Export CSV") {
+                                share(format: .csv)
+                            }
+                            Button("Export JSON") {
+                                share(format: .json)
+                            }
+                        } else {
+                            Button("CSV Export • Available in Pro") {}
+                                .disabled(true)
+                            Button("JSON Export • Available in Pro") {}
+                                .disabled(true)
                         }
                     } label: {
                         Image(systemName: "square.and.arrow.up")
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index ceda05e..0f42519 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -3,6 +3,7 @@ import Foundation
 struct DomainReport: Codable {
     let domain: String
     let timestamp: Date
+    let provenance: DomainReportProvenance
     let appVersion: String
     let resolverDisplayName: String
     let resolverURLString: String
@@ -29,6 +30,34 @@ struct DomainReport: Codable {
     let riskAssessment: DomainRiskAssessment
     let insights: [String]
     let changeSummary: DomainChangeSummary?
+    let workflowContext: DomainWorkflowContext?
+    let metadata: DomainReportMetadata
+}
+
+struct DomainReportProvenance: Codable {
+    let collectedAt: Date
+    let source: LookupResultSource
+    let sections: [LookupSectionKind: SectionProvenance]
+    let dataSources: [String]
+}
+
+struct DomainWorkflowContext: Codable {
+    let workflowID: UUID?
+    let workflowName: String?
+    let source: String
+}
+
+struct DomainReportMetadata: Codable {
+    let schemaVersion: String
+    let resolverDisplayName: String
+    let resolverURLString: String
+    let appVersion: String
+    let cachedSections: [LookupSectionKind]
+    let auditNote: String?
+    let validationIssues: [String]
+    let isPartialSnapshot: Bool
+    let errorDetails: [LookupSectionKind: InspectionFailure]
+    let statusMessage: String?
 }
 
 struct DNSResultSummary: Codable {
@@ -87,7 +116,11 @@ struct NetworkSummary: Codable {
 }
 
 struct DomainReportBuilder {
-    func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
+    func build(
+        from snapshot: LookupSnapshot,
+        previousSnapshot: LookupSnapshot? = nil,
+        workflowContext: DomainWorkflowContext? = nil
+    ) -> DomainReport {
         let primaryIP = primaryIPAddress(from: snapshot)
         let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
         let changeSummary: DomainChangeSummary?
@@ -108,6 +141,12 @@ struct DomainReportBuilder {
         return DomainReport(
             domain: snapshot.domain,
             timestamp: snapshot.timestamp,
+            provenance: DomainReportProvenance(
+                collectedAt: snapshot.timestamp,
+                source: snapshot.resultSource,
+                sections: snapshot.provenanceBySection,
+                dataSources: snapshot.dataSources
+            ),
             appVersion: snapshot.appVersion,
             resolverDisplayName: snapshot.resolverDisplayName,
             resolverURLString: snapshot.resolverURLString,
@@ -180,12 +219,29 @@ struct DomainReportBuilder {
             subdomainGroups: analysis.subdomainGroups,
             riskAssessment: analysis.riskAssessment,
             insights: analysis.insights,
-            changeSummary: changeSummary
+            changeSummary: changeSummary,
+            workflowContext: workflowContext,
+            metadata: DomainReportMetadata(
+                schemaVersion: "3.0.0",
+                resolverDisplayName: snapshot.resolverDisplayName,
+                resolverURLString: snapshot.resolverURLString,
+                appVersion: snapshot.appVersion,
+                cachedSections: snapshot.cachedSections,
+                auditNote: snapshot.note,
+                validationIssues: snapshot.validationIssues,
+                isPartialSnapshot: snapshot.isPartialSnapshot,
+                errorDetails: snapshot.errorDetails,
+                statusMessage: snapshot.statusMessage
+            )
         )
     }
 
-    func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
-        build(from: entry.snapshot, previousSnapshot: previousSnapshot)
+    func build(
+        from entry: HistoryEntry,
+        previousSnapshot: LookupSnapshot? = nil,
+        workflowContext: DomainWorkflowContext? = nil
+    ) -> DomainReport {
+        build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext)
     }
 
     private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 9b20066..b7d4606 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -36,25 +36,28 @@ enum DomainReportExporter {
             "DomainDig Report",
             "Domain: \(report.domain)",
             "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
-            "App Version: \(report.appVersion)",
-            "Resolver: \(report.resolverDisplayName)",
-            "Resolver URL: \(report.resolverURLString)",
-            "Source: \(report.resultSource.label)",
+            "App Version: \(report.metadata.appVersion)",
+            "Resolver: \(report.metadata.resolverDisplayName)",
+            "Resolver URL: \(report.metadata.resolverURLString)",
+            "Source: \(report.provenance.source.label)",
             "Availability: \(availabilityLabel(report.availability))",
             "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
         ]
 
-        if report.isPartialSnapshot {
+        if report.metadata.isPartialSnapshot {
             lines.append("Snapshot Integrity: Partial snapshot")
         }
-        if let auditNote = report.auditNote, !auditNote.isEmpty {
+        if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
             lines.append("Audit Note: \(auditNote)")
         }
-        if !report.dataSources.isEmpty {
-            lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))")
+        if !report.provenance.dataSources.isEmpty {
+            lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
         }
-        if !report.validationIssues.isEmpty {
-            lines.append("Validation: \(report.validationIssues.joined(separator: " | "))")
+        if !report.metadata.validationIssues.isEmpty {
+            lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
+        }
+        if let workflowContext = report.workflowContext {
+            lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
         }
 
         appendSection("Summary", to: &lines) {
@@ -349,6 +352,9 @@ enum DomainReportExporter {
             "data_sources",
             "audit_note",
             "partial_snapshot",
+            "workflow_name",
+            "workflow_source",
+            "cached_sections",
             "change_summary",
             "change_impact",
             "workflow_insights"
@@ -408,9 +414,12 @@ enum DomainReportExporter {
                 report.network.reachabilitySummary,
                 report.network.geolocationSummary,
                 report.geolocationConfidence?.rawValue ?? "",
-                report.dataSources.joined(separator: " | "),
-                report.auditNote ?? "",
-                report.isPartialSnapshot ? "true" : "false",
+                report.provenance.dataSources.joined(separator: " | "),
+                report.metadata.auditNote ?? "",
+                report.metadata.isPartialSnapshot ? "true" : "false",
+                report.workflowContext?.workflowName ?? "",
+                report.workflowContext?.source ?? "",
+                report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
                 report.changeSummary?.message ?? "",
                 report.changeSummary?.impactClassification.rawValue ?? "",
                 workflowInsightSummary