Commit 963ce3fb34

963ce3fb34bd6c6bd72f1aa956ce602267b90e9c

parent: 7156c088a4

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 17:14 UTC

feat: owner Pro+ allowlist via CloudKit, cut v5.0.1

Grants the app owner Pro+ without a purchase, keyed to their CloudKit
user-record ID so it works on the release App Store build.

- OwnerAccess holds the owner's CloudKit user-record ID (opaque, per-Apple-ID,
  scoped to the app's container; safe to publish — CloudKit verifies identity
  server-side, so it can't be presented by anyone else).
- PurchaseService resolves the allowlist against CloudKit once per launch and,
  on a match, records a persisted owner grant so it applies instantly and
  offline thereafter. The grant only ever elevates the tier to .proPlus and
  defers to the existing #if DEBUG overrides, so real purchases and free/pro
  testing are unaffected. cachedTier / cachedEntitlement were refactored to
  fall back to the owner grant only when no debug override or stored purchase
  applies.
- Supersedes the DEBUG record-ID reveal (PR #60): its only purpose was to read
  the owner's ID, which is now hardcoded, so the reveal is not shipped.

Release cut: MARKETING_VERSION 5.0.0 -> 5.0.1, CURRENT_PROJECT_VERSION 45 -> 46,
AppVersion.current -> 5.0.1, roadmap updated. App builds clean; unit suite 63/63.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +20 −20
@@ -567,11 +567,11 @@
567 BUNDLE_LOADER = "$(TEST_HOST)"; 567 BUNDLE_LOADER = "$(TEST_HOST)";
568 CLANG_ENABLE_OBJC_WEAK = NO; 568 CLANG_ENABLE_OBJC_WEAK = NO;
569 CODE_SIGN_STYLE = Automatic; 569 CODE_SIGN_STYLE = Automatic;
570 CURRENT_PROJECT_VERSION = 45; 570 CURRENT_PROJECT_VERSION = 46;
571 DEVELOPMENT_TEAM = ZCNAX3VL9D; 571 DEVELOPMENT_TEAM = ZCNAX3VL9D;
572 GENERATE_INFOPLIST_FILE = YES; 572 GENERATE_INFOPLIST_FILE = YES;
573 IPHONEOS_DEPLOYMENT_TARGET = 17.6; 573 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
574 MARKETING_VERSION = 5.0.0; 574 MARKETING_VERSION = 5.0.1;
575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; 575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
576 PRODUCT_NAME = "$(TARGET_NAME)"; 576 PRODUCT_NAME = "$(TARGET_NAME)";
577 SDKROOT = iphoneos; 577 SDKROOT = iphoneos;
@@ -714,7 +714,7 @@
714 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 714 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
715 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 715 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
716 CODE_SIGN_STYLE = Automatic; 716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 45; 717 CURRENT_PROJECT_VERSION = 46;
718 DEVELOPMENT_TEAM = ZCNAX3VL9D; 718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719 ENABLE_PREVIEWS = YES; 719 ENABLE_PREVIEWS = YES;
720 GENERATE_INFOPLIST_FILE = YES; 720 GENERATE_INFOPLIST_FILE = YES;
@@ -731,7 +731,7 @@
731 "$(inherited)", 731 "$(inherited)",
732 "@executable_path/Frameworks", 732 "@executable_path/Frameworks",
733 ); 733 );
734 MARKETING_VERSION = 5.0.0; 734 MARKETING_VERSION = 5.0.1;
735 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 735 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
736 PRODUCT_NAME = "$(TARGET_NAME)"; 736 PRODUCT_NAME = "$(TARGET_NAME)";
737 STRING_CATALOG_GENERATE_SYMBOLS = YES; 737 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -751,7 +751,7 @@
751 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 751 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
752 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 752 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
753 CODE_SIGN_STYLE = Automatic; 753 CODE_SIGN_STYLE = Automatic;
754 CURRENT_PROJECT_VERSION = 45; 754 CURRENT_PROJECT_VERSION = 46;
755 DEVELOPMENT_TEAM = ZCNAX3VL9D; 755 DEVELOPMENT_TEAM = ZCNAX3VL9D;
756 ENABLE_PREVIEWS = YES; 756 ENABLE_PREVIEWS = YES;
757 GENERATE_INFOPLIST_FILE = YES; 757 GENERATE_INFOPLIST_FILE = YES;
@@ -768,7 +768,7 @@
768 "$(inherited)", 768 "$(inherited)",
769 "@executable_path/Frameworks", 769 "@executable_path/Frameworks",
770 ); 770 );
771 MARKETING_VERSION = 5.0.0; 771 MARKETING_VERSION = 5.0.1;
772 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 772 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
773 PRODUCT_NAME = "$(TARGET_NAME)"; 773 PRODUCT_NAME = "$(TARGET_NAME)";
774 STRING_CATALOG_GENERATE_SYMBOLS = YES; 774 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -787,7 +787,7 @@
787 APPLICATION_EXTENSION_API_ONLY = YES; 787 APPLICATION_EXTENSION_API_ONLY = YES;
788 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; 788 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
789 CODE_SIGN_STYLE = Automatic; 789 CODE_SIGN_STYLE = Automatic;
790 CURRENT_PROJECT_VERSION = 45; 790 CURRENT_PROJECT_VERSION = 46;
791 DEVELOPMENT_TEAM = ZCNAX3VL9D; 791 DEVELOPMENT_TEAM = ZCNAX3VL9D;
792 GENERATE_INFOPLIST_FILE = NO; 792 GENERATE_INFOPLIST_FILE = NO;
793 INFOPLIST_FILE = DomainDigWidget/Info.plist; 793 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -798,7 +798,7 @@
798 "@executable_path/Frameworks", 798 "@executable_path/Frameworks",
799 "@executable_path/../../Frameworks", 799 "@executable_path/../../Frameworks",
800 ); 800 );
801 MARKETING_VERSION = 5.0.0; 801 MARKETING_VERSION = 5.0.1;
802 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; 802 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
803 PRODUCT_NAME = "$(TARGET_NAME)"; 803 PRODUCT_NAME = "$(TARGET_NAME)";
804 SKIP_INSTALL = YES; 804 SKIP_INSTALL = YES;
@@ -816,7 +816,7 @@
816 APPLICATION_EXTENSION_API_ONLY = YES; 816 APPLICATION_EXTENSION_API_ONLY = YES;
817 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; 817 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
818 CODE_SIGN_STYLE = Automatic; 818 CODE_SIGN_STYLE = Automatic;
819 CURRENT_PROJECT_VERSION = 45; 819 CURRENT_PROJECT_VERSION = 46;
820 DEVELOPMENT_TEAM = ZCNAX3VL9D; 820 DEVELOPMENT_TEAM = ZCNAX3VL9D;
821 GENERATE_INFOPLIST_FILE = NO; 821 GENERATE_INFOPLIST_FILE = NO;
822 INFOPLIST_FILE = DomainDigWidget/Info.plist; 822 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -827,7 +827,7 @@
827 "@executable_path/Frameworks", 827 "@executable_path/Frameworks",
828 "@executable_path/../../Frameworks", 828 "@executable_path/../../Frameworks",
829 ); 829 );
830 MARKETING_VERSION = 5.0.0; 830 MARKETING_VERSION = 5.0.1;
831 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; 831 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
832 PRODUCT_NAME = "$(TARGET_NAME)"; 832 PRODUCT_NAME = "$(TARGET_NAME)";
833 SKIP_INSTALL = YES; 833 SKIP_INSTALL = YES;
@@ -845,7 +845,7 @@
845 APPLICATION_EXTENSION_API_ONLY = YES; 845 APPLICATION_EXTENSION_API_ONLY = YES;
846 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; 846 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
847 CODE_SIGN_STYLE = Automatic; 847 CODE_SIGN_STYLE = Automatic;
848 CURRENT_PROJECT_VERSION = 45; 848 CURRENT_PROJECT_VERSION = 46;
849 DEVELOPMENT_TEAM = ZCNAX3VL9D; 849 DEVELOPMENT_TEAM = ZCNAX3VL9D;
850 GENERATE_INFOPLIST_FILE = NO; 850 GENERATE_INFOPLIST_FILE = NO;
851 INFOPLIST_FILE = DomainDigShareExtension/Info.plist; 851 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
@@ -856,7 +856,7 @@
856 "@executable_path/Frameworks", 856 "@executable_path/Frameworks",
857 "@executable_path/../../Frameworks", 857 "@executable_path/../../Frameworks",
858 ); 858 );
859 MARKETING_VERSION = 5.0.0; 859 MARKETING_VERSION = 5.0.1;
860 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; 860 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
861 PRODUCT_NAME = "$(TARGET_NAME)"; 861 PRODUCT_NAME = "$(TARGET_NAME)";
862 SKIP_INSTALL = YES; 862 SKIP_INSTALL = YES;
@@ -874,7 +874,7 @@
874 APPLICATION_EXTENSION_API_ONLY = YES; 874 APPLICATION_EXTENSION_API_ONLY = YES;
875 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; 875 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
876 CODE_SIGN_STYLE = Automatic; 876 CODE_SIGN_STYLE = Automatic;
877 CURRENT_PROJECT_VERSION = 45; 877 CURRENT_PROJECT_VERSION = 46;
878 DEVELOPMENT_TEAM = ZCNAX3VL9D; 878 DEVELOPMENT_TEAM = ZCNAX3VL9D;
879 GENERATE_INFOPLIST_FILE = NO; 879 GENERATE_INFOPLIST_FILE = NO;
880 INFOPLIST_FILE = DomainDigShareExtension/Info.plist; 880 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
@@ -885,7 +885,7 @@
885 "@executable_path/Frameworks", 885 "@executable_path/Frameworks",
886 "@executable_path/../../Frameworks", 886 "@executable_path/../../Frameworks",
887 ); 887 );
888 MARKETING_VERSION = 5.0.0; 888 MARKETING_VERSION = 5.0.1;
889 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; 889 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
890 PRODUCT_NAME = "$(TARGET_NAME)"; 890 PRODUCT_NAME = "$(TARGET_NAME)";
891 SKIP_INSTALL = YES; 891 SKIP_INSTALL = YES;
@@ -901,11 +901,11 @@
901 isa = XCBuildConfiguration; 901 isa = XCBuildConfiguration;
902 buildSettings = { 902 buildSettings = {
903 CODE_SIGN_STYLE = Automatic; 903 CODE_SIGN_STYLE = Automatic;
904 CURRENT_PROJECT_VERSION = 45; 904 CURRENT_PROJECT_VERSION = 46;
905 DEVELOPMENT_TEAM = ZCNAX3VL9D; 905 DEVELOPMENT_TEAM = ZCNAX3VL9D;
906 GENERATE_INFOPLIST_FILE = YES; 906 GENERATE_INFOPLIST_FILE = YES;
907 IPHONEOS_DEPLOYMENT_TARGET = 17.6; 907 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
908 MARKETING_VERSION = 5.0.0; 908 MARKETING_VERSION = 5.0.1;
909 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; 909 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
910 PRODUCT_NAME = "$(TARGET_NAME)"; 910 PRODUCT_NAME = "$(TARGET_NAME)";
911 SWIFT_APPROACHABLE_CONCURRENCY = YES; 911 SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -920,11 +920,11 @@
920 isa = XCBuildConfiguration; 920 isa = XCBuildConfiguration;
921 buildSettings = { 921 buildSettings = {
922 CODE_SIGN_STYLE = Automatic; 922 CODE_SIGN_STYLE = Automatic;
923 CURRENT_PROJECT_VERSION = 45; 923 CURRENT_PROJECT_VERSION = 46;
924 DEVELOPMENT_TEAM = ZCNAX3VL9D; 924 DEVELOPMENT_TEAM = ZCNAX3VL9D;
925 GENERATE_INFOPLIST_FILE = YES; 925 GENERATE_INFOPLIST_FILE = YES;
926 IPHONEOS_DEPLOYMENT_TARGET = 17.6; 926 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
927 MARKETING_VERSION = 5.0.0; 927 MARKETING_VERSION = 5.0.1;
928 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; 928 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
929 PRODUCT_NAME = "$(TARGET_NAME)"; 929 PRODUCT_NAME = "$(TARGET_NAME)";
930 SWIFT_APPROACHABLE_CONCURRENCY = YES; 930 SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -941,11 +941,11 @@
941 BUNDLE_LOADER = "$(TEST_HOST)"; 941 BUNDLE_LOADER = "$(TEST_HOST)";
942 CLANG_ENABLE_OBJC_WEAK = NO; 942 CLANG_ENABLE_OBJC_WEAK = NO;
943 CODE_SIGN_STYLE = Automatic; 943 CODE_SIGN_STYLE = Automatic;
944 CURRENT_PROJECT_VERSION = 45; 944 CURRENT_PROJECT_VERSION = 46;
945 DEVELOPMENT_TEAM = ZCNAX3VL9D; 945 DEVELOPMENT_TEAM = ZCNAX3VL9D;
946 GENERATE_INFOPLIST_FILE = YES; 946 GENERATE_INFOPLIST_FILE = YES;
947 IPHONEOS_DEPLOYMENT_TARGET = 17.6; 947 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
948 MARKETING_VERSION = 5.0.0; 948 MARKETING_VERSION = 5.0.1;
949 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; 949 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests;
950 PRODUCT_NAME = "$(TARGET_NAME)"; 950 PRODUCT_NAME = "$(TARGET_NAME)";
951 SDKROOT = iphoneos; 951 SDKROOT = iphoneos;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 nonisolated static var current: String { 4 nonisolated static var current: String {
5 "5.0.0" 5 "5.0.1"
6 } 6 }
7} 7}
DomainDig/OwnerAccess.swift added +34
@@ -0,0 +1,34 @@
1import CloudKit
2
3/// Owner-only entitlement support. The app owner is identified by their CloudKit
4/// user-record ID — a stable, opaque per-Apple-ID value for this app's container.
5/// `PurchaseService` grants the owner Pro+ when the signed-in iCloud user matches,
6/// so the owner does not need a purchase.
7///
8/// Publishing the record ID here is safe: it is not an Apple ID or any personal
9/// identifier, it is scoped to the `iCloud.net.cleberg.DomainDig` container, and
10/// CloudKit identity is verified server-side — another user cannot present it as
11/// their own. An empty value makes the allowlist inert.
12enum OwnerAccess {
13 static let ownerUserRecordID = "_1c35d6a25540b3ef00023cc0425ec373"
14
15 static var isConfigured: Bool { !ownerUserRecordID.isEmpty }
16
17 /// The current iCloud user's record name for this app's container, or nil if
18 /// it is unavailable (not signed into iCloud, restricted, or offline before
19 /// the first fetch).
20 static func currentUserRecordName() async -> String? {
21 do {
22 return try await CKContainer.default().userRecordID().recordName
23 } catch {
24 return nil
25 }
26 }
27
28 /// True only when the allowlist is configured and the current iCloud user is
29 /// the owner.
30 static func isOwner() async -> Bool {
31 guard isConfigured else { return false }
32 return await currentUserRecordName() == ownerUserRecordID
33 }
34}
DomainDig/PurchaseService.swift +50 −3
@@ -34,6 +34,20 @@ final class PurchaseService {
34 private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" 34 private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
35 #endif 35 #endif
36 36
37 private static let ownerEntitlementKey = "purchase.ownerEntitlement"
38
39 /// Whether the owner allowlist has confirmed this device's iCloud user as the
40 /// owner. Persisted so the grant is instant on later launches and survives
41 /// offline, when CloudKit cannot be reached.
42 static var ownerEntitlementGranted: Bool {
43 UserDefaults.standard.bool(forKey: ownerEntitlementKey)
44 }
45
46 private static var storedEntitlement: CachedEntitlement? {
47 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
48 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
49 }
50
37 static var cachedEntitlement: CachedEntitlement? { 51 static var cachedEntitlement: CachedEntitlement? {
38 #if DEBUG 52 #if DEBUG
39 if let forcedEntitlement = debugForcedEntitlement { 53 if let forcedEntitlement = debugForcedEntitlement {
@@ -41,12 +55,17 @@ final class PurchaseService {
41 } 55 }
42 #endif 56 #endif
43 57
44 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } 58 return storedEntitlement
45 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
46 } 59 }
47 60
48 static var cachedTier: FeatureTier { 61 static var cachedTier: FeatureTier {
49 cachedEntitlement?.tier ?? .free 62 #if DEBUG
63 // A debug override wins outright so free/pro tiers remain testable on the
64 // owner's own device.
65 if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier }
66 #endif
67 if ownerEntitlementGranted { return .proPlus }
68 return storedEntitlement?.tier ?? .free
50 } 69 }
51 70
52 var products: [Product] = [] 71 var products: [Product] = []
@@ -64,8 +83,10 @@ final class PurchaseService {
64 currentTier = Self.cachedTier 83 currentTier = Self.cachedTier
65 activeProductID = Self.cachedEntitlement?.activeProductID 84 activeProductID = Self.cachedEntitlement?.activeProductID
66 applyDebugOverrideIfNeeded() 85 applyDebugOverrideIfNeeded()
86 applyOwnerOverrideIfNeeded()
67 updatesTask = observeTransactionUpdates() 87 updatesTask = observeTransactionUpdates()
68 Task { 88 Task {
89 await resolveOwnerEntitlementIfNeeded()
69 await refreshProducts() 90 await refreshProducts()
70 await refreshEntitlements() 91 await refreshEntitlements()
71 } 92 }
@@ -118,6 +139,7 @@ final class PurchaseService {
118 currentTier = tier(for: activeProductID) 139 currentTier = tier(for: activeProductID)
119 persistCurrentEntitlement() 140 persistCurrentEntitlement()
120 applyDebugOverrideIfNeeded() 141 applyDebugOverrideIfNeeded()
142 applyOwnerOverrideIfNeeded()
121 } 143 }
122 144
123 func purchase(_ product: Product) async { 145 func purchase(_ product: Product) async {
@@ -251,6 +273,31 @@ final class PurchaseService {
251 #endif 273 #endif
252 } 274 }
253 275
276 /// Elevates the current tier to Pro+ when the device's iCloud user has been
277 /// confirmed as the owner. Only ever elevates, and defers to a debug override
278 /// so free/pro tiers stay testable on the owner's own device.
279 private func applyOwnerOverrideIfNeeded() {
280 #if DEBUG
281 if Self.debugForcedEntitlement != nil { return }
282 #endif
283 guard Self.ownerEntitlementGranted else { return }
284 currentTier = .proPlus
285 }
286
287 /// Resolves the owner allowlist against CloudKit once per launch. On a match
288 /// it records the grant so future launches apply it synchronously and offline.
289 private func resolveOwnerEntitlementIfNeeded() async {
290 guard OwnerAccess.isConfigured else { return }
291 if Self.ownerEntitlementGranted {
292 applyOwnerOverrideIfNeeded()
293 return
294 }
295 if await OwnerAccess.isOwner() {
296 UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey)
297 applyOwnerOverrideIfNeeded()
298 }
299 }
300
254 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { 301 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
255 switch result { 302 switch result {
256 case .verified(let transaction): 303 case .verified(let transaction):
RELEASE_ROADMAP.md +12 −1
@@ -6,7 +6,7 @@ ports, geolocation, subdomains, availability). The next several releases invest
6in *reach and surfacing* — getting that data onto more iOS surfaces and into more 6in *reach and surfacing* — getting that data onto more iOS surfaces and into more
7workflows — rather than adding raw protocol checks. 7workflows — rather than adding raw protocol checks.
8 8
9Current version: `v5.0.0`. 9Current version: `v5.0.1`.
10 10
11## v4.4.1 Patch: Release Readiness — ✅ shipped 11## v4.4.1 Patch: Release Readiness — ✅ shipped
12 12
@@ -310,6 +310,17 @@ Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION`
31044 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is 31044 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is
311the only step left, and it is a manual action outside the repo. 311the only step left, and it is a manual action outside the repo.
312 312
313## v5.0.1 Patch: Owner entitlement — ✅ shipped
314
315- **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their
316 CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's
317 container). `PurchaseService` resolves it against CloudKit once per launch and,
318 on a match, grants `.proPlus` — persisted so it applies instantly and offline
319 thereafter. It only ever elevates the tier and defers to the existing `#if
320 DEBUG` overrides, so real purchases and free/pro testing are unaffected.
321- Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION`
322 45 → 46, `AppVersion.current` in lockstep.
323
313## Cross-cutting note 324## Cross-cutting note
314 325
315New feature surfaces (widgets, intents, extensions) each add a target and a 326New feature surfaces (widgets, intents, extensions) each add a target and a