Second independent audit of the four commits on T-2237/bugfix-restore-purchases-false-negative (PR #389) against origin/main. Re-traces the blocker (F1) and major (F2) raised by the first pass by hand against the new code. Findings only — nothing was fixed in this pass. prismTests/StoreManagerTests was executed: 36/36 green.
staleHeldBackResultDoesNotBeatNewerScanDuringBootstrap, restoring the defer kills cancelledScanReturnsPromotedResultInsteadOfStaleLoading.entitlementState without it. The rule lastCommittedObservation >= observation ? entitlementState : result is sound in both directions..loading cancellation exit is now stated in the contract, pinned by cancelledScanDuringBootstrapReturnsLoading, and kept unreachable by two .disabled guards. Escapability confirmed: prismApp.swift:436 re-scans on every foreground while not .unlocked, so the disabled Restore row cannot strand a user.AppStoreSync.swift's doc comment scopes it to. restorePurchasesReportsItsOwnScanNotStaleSharedState executes green and goes red on the revert.sync() call is still unpinned — StubAppStoreSync records nothing, so deleting try await appStoreSync.sync() leaves all 36 tests green. Pre-existing gap, but now two lines from closed.make lint 0 violations, make verify-test-isolation 43/43 OK, xcodebuild test -only-testing:prismTests/StoreManagerTests 36/36 passed (result bundle verified by Tools/check-test-results.sh, non-zero test count). The macOS Debug build of the whole scheme compiled as part of that run.Ready to push
Every finding from the first pass is genuinely closed, and I re-derived the two that mattered rather than taking the commit message's word for it.
F1 (promotion before commit) is fixed: promotePendingLoadingFallbackIfUnblocked() now runs after the commit attempt on both return paths, the defer is gone, and both wrong shapes are pinned by distinct executed tests. F2 (stamped ≠ committed) is fixed by lastCommittedObservation. I checked every production path that writes entitlementState — both committing branches of commitEntitlement, the promotion, the transaction-event path, the bootstrap scan — and the watermark advances on all of them and on none of the two drop paths. The invariant that makes the rule work (watermark > 0 ⇒ state ≠ .loading) holds, which is why the .loading case is subsumed rather than special-cased, exactly as Decision 15 claims.
The AppStoreSyncing seam does not change production behaviour: the default argument is StoreKitAppStoreSync(), whose sync() is try await AppStore.sync() — byte-for-byte what restorePurchases() called before. Decision 15 is a correctly-formed full Enhanced Nygard entry with a unique sequential ID.
What remains is four minors and some doc-accuracy nits, none of which is a correctness risk and none of which blocks the push. The one I would actually spend two lines on is M1.
0e596489 Fix T-2237: restorePurchases can tell a paying customer "No purchases to restore" 4d18a00b Fix T-2237: verifyEntitlements returned stale own-scan snapshot instead of freshest committed state 88d22557 Fix T-2237: cancelled scan could still return stale .loading, guard PaywallSheet restore d1528574 Fix T-2237: judge the read-back by publication, not by stamping Prism sells a one-time unlock. If you already bought it and press “Restore Purchases”, the app is supposed to say “Purchases restored.” A real owner could instead be told “No purchases to restore.”
Restore asks the App Store to re-check what you own, then reports an answer. The bug was which answer it reported: instead of reporting what its own check found, it read a shared app-wide variable that several parts of the app write to. If that variable had not been updated yet, restore reported the old value — which is “locked”, i.e. “nothing to restore”.
The obvious fix — always report your own check's result — is wrong the other way. If an actual purchase completes while your check is running, that purchase is newer news than your check, and reporting your own (now out-of-date) reading would say “no purchases” at the exact moment the purchase unlocked them. That version shipped on this branch and was reverted.
The app now keeps a second counter: not just “who started looking most recently” but “whose answer was actually written down most recently”. Restore reports the shared value only if something at least as recent as its own check was really written down; otherwise it reports its own finding. And the Restore buttons are greyed out while the very first check is still running, because during that window the app genuinely does not know yet.
StoreManager is @MainActor, which serialises synchronous regions only. verifyEntitlements() is async and suspends at await entitlementSource.hasUnlockEntitlement(), reopening the actor to two other writers of entitlementState: a completed purchase(_:) and the Transaction.updates listener, both via applyTransactionEvent. T-1868 solved “whose write wins” with a monotonic observation clock: every writer stamps before gathering evidence, and commitEntitlement applies a write only while its stamp is still the latest. Recency of information decides, never completion order.
T-2237 is the read side of the same problem. restorePurchases() needs to attribute an answer to its own call, but the stamp clock cannot answer “did anything at or after my observation actually get published?” — it only answers “has anyone newer started?”. A newer scan suspended in hasUnlockEntitlement() has stamped and written nothing, and outside the .loading bootstrap window commitEntitlement drops the resolving scan's write with no fallback, leaving the shared property strictly older than what that scan found.
The branch adds lastCommittedObservation, advanced at exactly the three sites that genuinely publish a value, and the return becomes lastCommittedObservation >= observation ? entitlementState : result.
promotePendingLoadingFallbackIfUnblocked() moves out of a defer and onto both explicit return paths, after the commit attempt — a defer fires after the return value is captured, and hoisting it before the commit inverts T-2152. And AppStore.sync() moves behind an AppStoreSyncing protocol, mirroring T-1868's EntitlementSource, so restorePurchases() itself becomes unit-testable rather than only the scan beneath it.
The rule is sound because of an invariant the code never states outright: watermark > 0 ⇒ entitlementState ≠ .loading. Every site that advances lastCommittedObservation publishes .locked or .unlocked, and .loading is assigned only at init in non-DEBUG builds. Consequently, while the state is .loading the watermark is 0 and every scan (stamps start at 1) returns its own result. That is precisely why the T-2152 held-back case is subsumed rather than special-cased.
Outside .loading, the stale-but-loading branch of commitEntitlement (line 547's guard) is unreachable, so the only publisher is the unconditional branch, whose stamp is by construction the largest ever issued. The watermark is therefore monotonic and truthful, and the max(…) at line 567 is a no-op there (load-bearing only at 559 and 595). Case analysis of the return:
entitlementState (newer). Correct.entitlementState. This is T-2237's second half, and the reason returning result unconditionally was reverted.result. This is the T-2237 symptom's most likely real path, since AppStore.sync()'s auth sheet drives a scene-phase round trip that starts exactly such a scan (prismApp.swift:436).if entitlementState != next so the watermark advances even on a value-equal commit — genuinely load-bearing, and pinned.Production writers of entitlementState: commitEntitlement:558 (stale-but-loading publish), :563 (fresh publish), promotePendingLoadingFallbackIfUnblocked:594. All three advance the watermark. The two drop paths (:547 non-loading stale, :555 held-back) return before any assignment and advance nothing. applyTransactionEvent and the initTask bootstrap both route through commitEntitlement. No production path writes the state without advancing the watermark; none advances it on a dropped write. Two DEBUG/test-only writers — init(exportCounter:entitlementState:):219 and setEntitlementStateForTesting:641 — seed without advancing it; the former is correct (a seed is not an observation), the latter is a wart (see M3).
The defer shape and the hoisted shape fail differently, and the suite kills each with a different test — which is the right structure, since a single test covering both would not distinguish them. outstandingScanObservations.remove precedes the cancellation guard, so no path leaks a stamp; the residual (a hasUnlockEntitlement() that never returns pins a stamp forever and permanently vetoes promotion) is pre-existing and documented at :586-590.
StoreKitAppStoreSync.sync() is try await AppStore.sync(), injected as a default argument on both initialisers and stored in an @ObservationIgnored let. Production behaviour is unchanged, including error propagation into performRestore()'s catch (req 6.4 / 7.2). The one wrinkle is that the test-only initialiser also defaults to the live implementation (see M2).
prism/Services/StoreManager.swift
Why it matters. This is the whole fix. The pre-existing observation clock records who last STAMPED; nothing recorded who last PUBLISHED. Without that second fact, a scan cannot tell 'someone newer wrote a fresher answer' from 'someone newer merely started and wrote nothing' — and the two demand opposite return values.
What to look at. StoreManager.swift:141-159 (declaration), :408 (the rule), :559 / :567 / :595 (the three advance sites)
prism/Services/StoreManager.swift
Why it matters. Regression risk in both directions, and the branch shipped both wrong shapes before landing the right one. A defer fires after the return value is captured, so a cancelled scan whose own removal unblocked a promotion still returned the pre-promotion .loading. Hoisting the call before commitEntitlement inverts T-2152 outright.
What to look at. StoreManager.swift:359-400 (the ordering comment and both call sites)
prism/Services/AppStoreSync.swift
Why it matters. Answers F4 from the first pass. With AppStore.sync() welded in, no unit test could drive restorePurchases() at all, so its one-line return-value wiring — the actual subject of this ticket — could be reverted with the whole suite green.
What to look at. AppStoreSync.swift:1-28 (new file); StoreManager.swift:118, :194, :214, :283
prism/Views/PaywallSheet.swift
Why it matters. The cancelled-scan exit of verifyEntitlements() can genuinely return .loading, and both restore call sites render that as 'No purchases to restore.' — the ticket's exact symptom. The service does not prevent it; these two guards do.
What to look at. PaywallSheet.swift:157-169, SettingsView.swift:439
Decision 15, full Enhanced Nygard entry. Four alternatives, each with a rejection reason, one of which (return the scan's own finding unconditionally) shipped on this branch and was reverted. Both Positive and Negative consequences present, Impact section included, correct tier, unique sequential ID, --- separator, Quick Decisions table still ahead of all full entries. Format-compliant, no findings.
Recorded in a 24-line code comment rather than the decision log, which is defensible — it is a statement-ordering invariant, not an architectural choice, and it belongs where a future editor will move the line. The comment does narrate two abandoned implementations from this branch; that history arguably belongs in Decision 15's Context, where the parallel narrative for the return value already lives.
The .loading exit could have been handled inside verifyEntitlements() (e.g. by returning an optional, or by never returning .loading). Instead the service returns it and two views refuse to call while it is possible. Decision 15 records this as a consequence but not as a considered alternative — the trade (a testable service contract with an external precondition, vs. a total service contract) is the one an alternative entry would have captured.
Not stated anywhere, but it is what makes the .disabled guards safe: prismApp.swift:436 re-runs verifyEntitlements() on every scene-phase .active while not .unlocked, so a hung bootstrap scan is recoverable by backgrounding and foregrounding. The Settings Restore row was previously the only user-initiated lever; the replacement is not discoverable, but it exists.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| blocker | F1 — promotion before commit (first pass) | Commit 88d22557 hoisted promotePendingLoadingFallbackIfUnblocked() ahead of commitEntitlement, inverting T-2152: a three-scan bootstrap interleaving published the stale reading and dropped the fresh one. | Verified closed by hand. Promotion now runs after the commit attempt on both return paths (StoreManager.swift:394, :400); the defer is gone. Both wrong shapes are pinned by distinct tests, and staleHeldBackResultDoesNotBeatNewerScanDuringBootstrap executed green. |
| major | F2 — stamped is not committed (first pass) | The read-back could return a state OLDER than the scan's own finding whenever a newer scan had stamped but not committed, leaving T-2237's symptom standing on the restore -> auth sheet -> foreground-scan path. | Verified closed by hand. lastCommittedObservation advances at all three publish sites and at neither drop site; applyTransactionEvent and the initTask bootstrap both route through commitEntitlement and are covered. The invariant 'watermark > 0 implies state is not .loading' holds, which is what makes the single rule subsume the .loading case. |
| major | F3 — .loading escaping via the cancellation return | The doc comment promised the return was only .unlocked / .locked; the cancellation path could return .loading, which both restore call sites render as 'No purchases to restore.' | Closed honestly rather than by suppression: the exit is now stated in the contract at :335-347, pinned by cancelledScanDuringBootstrapReturnsLoading (executed green), and kept unreachable by .disabled guards on both Restore controls. Escapability of .loading confirmed independently at prismApp.swift:436. |
| major | F4 — restorePurchases() wiring unpinned | A revert to 'await verifyEntitlements(); return entitlementState' compiled and passed every test in the suite. | Closed by the AppStoreSyncing seam. restorePurchasesReportsItsOwnScanNotStaleSharedState (StoreManagerTests.swift:806) executed green and goes red on that exact revert: the newer scan stamps observation 2 and stays suspended, restore's observation-1 commit is dropped, the watermark stays at 0, so the return is .unlocked while entitlementState is .locked. |
| minor | M1 — the sync() call itself is unpinned | StubAppStoreSync (StoreManagerTests.swift:871) records nothing and no test asserts either that sync() ran or that its throw propagates. Deleting 'try await appStoreSync.sync()' from restorePurchases() leaves all 36 tests green — restore silently stops re-syncing the App Store account. This is certain rather than inferred: a no-op stub with no recorded state cannot be asserted on, and it suspends nowhere, so removing the call is observationally identical. The throwing path is req 6.4 / 7.2's failure signal and the one Decision 11 signal with no test at all. | Not fixed (audit only). Pre-existing — there was no restorePurchases() test before this branch, so nothing regressed — but the seam this branch adds makes it a two-line close: a 'private(set) var syncCount' on the stub plus one #expect, and a throwing variant asserting restorePurchases() rethrows without running a scan. Worth doing while the file is open; not worth blocking the push. |
| minor | M2 — test-only init defaults to the live App Store sync | StoreManager.swift:214: the test-only init(exportCounter:entitlementState:...) defaults appStoreSync to the live StoreKitAppStoreSync(). That initialiser exists specifically so 'the PrismApp-owned instance must not contact StoreKit when running under xctest' (its own comment at :176-181), and convenience init() routes to it under isUnitTestHost. restorePurchases() on such an instance now reaches the real AppStore.sync(). The same shape pre-exists for entitlementSource, but that one is unreachable from this init's paths, whereas Restore is a user-tappable control. | Not fixed (audit only). Low practical impact — UI tests would have to tap Restore — but a no-op default on the test-only initialiser would honour its stated contract at zero cost. |
| minor | M3 — setEntitlementStateForTesting bypasses the watermark | StoreManager.swift:641 (DEBUG) publishes without advancing lastCommittedObservation, making it the one writer that leaves the watermark disagreeing with the state. A DEBUG test that seeds via it and then runs a superseded scan gets a return the new invariant does not describe. It also falsifies the contract's load-bearing sentence at :339 — '.loading is a one-way door, no writer ever assigns it after init' — since it will assign whatever it is given, including .loading. | Not fixed (audit only). Either route it through commitEntitlement, or say 'no non-DEBUG writer' at :339 and note the exclusion at :155. No production consequence; the concern is that the safety argument for the cancellation exit rests on that sentence. |
| minor | M4 — the .loading held-back watermark advance is unasserted | StoreManager.swift:559 (the watermark advance in the stale-but-.loading commit branch) is not reachable by any assertion in the suite: in that branch the committed value is always the committing scan's own result, so its own return is identical either way. The case it actually matters for — scan A(1) suspended, scan B(2) commits via this branch, A resumes and should report B's fresher value — has no test. | Not fixed (audit only). Either add that interleaving or drop the line; as written it reads as pinned and is not. Note the contrast with :567, whose placement OUTSIDE the 'if entitlementState != next' is genuinely pinned — that one earns its comment. |
| nit | Decision 15 overstates one accessibility consequence | decision_log.md, Decision 15 Consequences: 'removed from the VoiceOver focus order while disabled' is not what SwiftUI .disabled does — it applies the .notEnabled trait, and VoiceOver still focuses the element and announces it as dimmed. The stated consequence is worse than the actual behaviour. | Not fixed (audit only). One clause. Everything else in Decision 15 is format-compliant and factually checks out against the code. |
| nit | Comment referent is ambiguous | StoreManager.swift:379: 'This explicit call, unlike the defer, is not unwind-safe' sits immediately above outstandingScanObservations.remove(observation), which was explicit on origin/main too and was never inside the removed defer. The sentence is about the promotion call and is correct about it; the placement makes the referent read as the remove line. | Not fixed (audit only). The substance underneath — a throwing hasUnlockEntitlement() would leak the stamp and strand .loading — is right and worth keeping. |
| nit | Comment-to-code ratio and duplicated argument | 153 of 197 added production lines are comments. Two specific spots: :363-386 spends 24 lines narrating two abandoned implementations from this branch to justify one statement's position, and :306-346's closing paragraph restates Decision 15's second Negative consequence near-verbatim. Two copies of the same argument in two files will diverge. | Not fixed (audit only). The invariants themselves are non-obvious and do deserve comments; it is the branch-development history that belongs in the decision log, where it already is. |
| nit | Seam naming and file placement | AppStoreSync.swift is named after neither the protocol (AppStoreSyncing) nor the implementation (StoreKitAppStoreSync), where EntitlementSource.swift is named after its protocol. The naming also drifts from the seam it explicitly copies: role noun vs gerund, and StoreKitAppStoreSync stutters StoreKit/AppStore. Two ~30-line files whose doc comments only make sense read together is an argument for one file. | Not fixed (audit only). Cosmetic. |
| nit | design.md not amended | specs/inapp-purchase/design.md:251-253 and :282-292 still show 'func restorePurchases() async' (void, non-throwing) and a private verifyEntitlements() assigning entitlementState directly. Stale since Decision 11 / T-1868 / T-2152, not introduced here — but Decision 14 set the precedent of amending design.md in place, and Decision 15 does not follow it. | Not fixed (audit only). Pre-existing drift; worth one commit while the blocks are being reasoned about. |
| nit | Paywall Restore dims with no explanation | PaywallSheet.swift:169: PaywallPresenter.present() has no .loading gate — only runGatedExport no-ops on it — so the Settings 'Unlock Unlimited Exports' row can present the paywall mid-bootstrap, where stateMessage renders EmptyView for .ready and the user sees a greyed-out Restore with nothing else. SettingsView at least shows a ProgressView on its unlock row. | Not fixed (audit only). An .accessibilityHint or an inline 'Checking your purchases…' would cover it. The window is one Transaction.currentEntitlements scan. |
Click to expand.
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex 5ee70841..5c8c9955 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -111,6 +111,12 @@ final class StoreManager { @ObservationIgnored private let entitlementSource: EntitlementSource + /// Backing App Store re-sync for `restorePurchases()`. Injectable so a+ /// test can drive restore itself, not merely the scan underneath it+ /// (T-2237).+ @ObservationIgnored+ private let appStoreSync: AppStoreSyncing+ /// Monotonic "observation clock" that orders every write to /// `entitlementState` by the RECENCY OF THE INFORMATION behind it, not by /// the order in which writes happen to finish (T-1868).@@ -132,6 +138,26 @@ final class StoreManager { @ObservationIgnored private var latestEntitlementObservation: UInt64 = 0 + /// Stamp of the most recent observation whose evidence actually reached+ /// `entitlementState` — the "committed" watermark, as opposed to+ /// `latestEntitlementObservation`, which records the most recent writer to+ /// have merely STAMPED (T-2237).+ ///+ /// The two differ whenever a writer takes a stamp and then suspends: a+ /// newer scan sitting in `hasUnlockEntitlement()` has bumped the clock but+ /// written nothing. `verifyEntitlements()` needs the committed watermark,+ /// not the stamp clock, to answer "did anything at or after my own+ /// observation actually get published?" — because when the answer is no,+ /// `entitlementState` is STALER than this scan's own finding and reading+ /// it back would hand the caller an older answer than the one it just+ /// gathered (which is T-2237's symptom, one interleaving over).+ ///+ /// Updated at every point that genuinely publishes a value: both+ /// committing branches of `commitEntitlement(_:observedAt:)` and+ /// `promotePendingLoadingFallbackIfUnblocked()`.+ @ObservationIgnored+ private var lastCommittedObservation: UInt64 = 0+ /// Stamps of `verifyEntitlements()` scans that have taken an observation /// stamp but not yet resolved (committed or discovered they never will, /// e.g. cancellation). Only scans are ever "outstanding" — a transaction@@ -164,15 +190,17 @@ final class StoreManager { init( exportCounter: ExportCounter,- entitlementSource: EntitlementSource = StoreKitEntitlementSource()+ entitlementSource: EntitlementSource = StoreKitEntitlementSource(),+ appStoreSync: AppStoreSyncing = StoreKitAppStoreSync() ) { self.exportCounter = exportCounter self.entitlementSource = entitlementSource+ self.appStoreSync = appStoreSync transactionListener = listenForTransactions() initTask = Task { [weak self] in guard let self else { return } async let products: Void = self.fetchProducts()- async let entitlements: Void = self.verifyEntitlements()+ async let entitlements: EntitlementState = self.verifyEntitlements() _ = await (products, entitlements) } }@@ -182,10 +210,12 @@ final class StoreManager { init( exportCounter: ExportCounter, entitlementState: EntitlementState,- entitlementSource: EntitlementSource = StoreKitEntitlementSource()+ entitlementSource: EntitlementSource = StoreKitEntitlementSource(),+ appStoreSync: AppStoreSyncing = StoreKitAppStoreSync() ) { self.exportCounter = exportCounter self.entitlementSource = entitlementSource+ self.appStoreSync = appStoreSync self.entitlementState = entitlementState } @@ -239,14 +269,25 @@ final class StoreManager { } } - /// Re-syncs entitlements with the App Store. Returns the resulting- /// `entitlementState` on success, or throws so the UI can show an error- /// message (req 6.4 / 7.2 — restore needs user-visible feedback).+ /// Re-syncs entitlements with the App Store. Returns the freshest known+ /// entitlement state as of this call, or throws so the UI can show an+ /// error message (req 6.4 / 7.2 — restore needs user-visible feedback).+ ///+ /// Delegates entirely to `verifyEntitlements()`'s return value (T-2237):+ /// see that function's doc comment for why "freshest known", not "this+ /// scan's own reading", is the right answer, and for the one case+ /// (`.loading`) where those two differ and the scan's own reading is used+ /// instead. @discardableResult func restorePurchases() async throws -> EntitlementState {- try await AppStore.sync()- await verifyEntitlements()- return entitlementState+ try await appStoreSync.sync()+ // Forward the scan's OWN return value. Re-reading `entitlementState`+ // here instead is the T-2237 bug: this call's answer would then be+ // whatever the shared property happens to hold, which any of three+ // writers may have moved — or not yet moved, if the scan's commit was+ // dropped in favour of a newer scan that has not published. Pinned by+ // `restorePurchasesReportsItsOwnScanNotStaleSharedState`.+ return await verifyEntitlements() } func retryProductFetch() async {@@ -264,7 +305,48 @@ final class StoreManager { } /// Re-checks entitlements after foreground transitions or other events.- func verifyEntitlements() async {+ /// Returns the FRESHEST KNOWN entitlement state as of this call, which is+ /// not always this scan's own reading of the world (T-2237).+ ///+ /// A caller that needs "the outcome of my own call" (e.g.+ /// `restorePurchases()`) must read the return value, not+ /// `entitlementState` read some arbitrary time later — but the return+ /// value must not simply echo what this scan saw either. If a+ /// `Transaction.updates` delivery lands while this scan is suspended in+ /// `hasUnlockEntitlement()`, that event carries newer evidence and wins+ /// the write (T-1868 ordering) — it commits `.unlocked` while this scan's+ /// own, now-stale, reading is `.locked`. Returning the scan's own reading+ /// in that case told a caller "no purchases" at the exact moment the+ /// transaction unlocked them.+ ///+ /// The rule that gets both halves right is a question the stamp clock+ /// cannot answer: **did anything at or after this call's own observation+ /// actually get published?** `observation != latestEntitlementObservation`+ /// only means a newer writer has STAMPED. When that writer is a newer scan+ /// still suspended, nothing newer has been WRITTEN, and outside `.loading`+ /// `commitEntitlement` drops this scan's write with no fallback — so+ /// `entitlementState` is then strictly OLDER than what this scan found.+ /// `lastCommittedObservation` records publication rather than stamping, so+ /// the return is `entitlementState` when something at or after this+ /// observation has committed, and this scan's own finding otherwise. That+ /// subsumes the `.loading` held-back case (T-2152's bootstrap window)+ /// rather than special-casing it: nothing has published there either.+ ///+ /// One exit does NOT satisfy that contract, deliberately: a cancelled scan+ /// has no finding of its own, so it returns `entitlementState` — which+ /// during the bootstrap window can genuinely be `.loading`, a value both+ /// restore call sites render as "No purchases to restore". Nothing in this+ /// function prevents that; what does is (a) `.loading` is a one-way door —+ /// no writer ever assigns it after `init` — so it is reachable only before+ /// the first commit, and (b) the restore controls are `.disabled` while+ /// `entitlementState == .loading` (`SettingsView.swift`,+ /// `PaywallSheet.swift`), and their `Task`s are unstructured button+ /// actions that nothing cancels. Both facts live outside this file; a new+ /// restore call site must re-establish (b) or handle `.loading` itself.+ /// `cancelledScanDuringBootstrapReturnsLoading` pins the exit honestly+ /// rather than pretending it cannot happen.+ @discardableResult+ func verifyEntitlements() async -> EntitlementState { // T-1868: stamp the observation BEFORE suspending. `await` yields the // MainActor, so a purchase or a `Transaction.updates` delivery can land // (and a later scan can start) while `hasUnlockEntitlement()` runs. The@@ -275,18 +357,55 @@ final class StoreManager { let hasUnlock = await entitlementSource.hasUnlockEntitlement() // This scan is resolving one way or another — commit or not, it is no- // longer outstanding. Do this before touching `pendingLoadingFallback`- // so a held-back result blocked only by this scan can be promoted- // below (T-2152).+ // longer outstanding, so a result held back only by this scan can now+ // be promoted (T-2152).+ //+ // The promotion itself is called explicitly on each return path,+ // NEVER once here before the commit, and never from a `defer`. Both+ // wrong shapes have shipped on this branch:+ // - `defer` fires after the return value is captured, so a cancelled+ // scan whose own removal unblocked a promotion still handed back+ // the pre-promotion `.loading`.+ // - promoting BEFORE `commitEntitlement` inverts T-2152 outright:+ // with scans A(1), B(2), E(3) all during `.loading` and E+ // cancelled, promotion at B's resumption sees nothing outstanding+ // above 1 and publishes A's OLDER reading, after which+ // `commitEntitlement(B, 2)` finds its stamp stale AND the state no+ // longer `.loading`, and drops B's fresher finding entirely.+ // Promoting at the position the `defer` occupied — after the commit+ // attempt, before the return value is read — keeps both properties.+ // `staleHeldBackResultDoesNotBeatNewerScanDuringBootstrap` pins it.+ //+ // This explicit call, unlike the `defer`, is not unwind-safe: it is+ // only guaranteed to run because `hasUnlockEntitlement()` is+ // non-throwing (a non-throwing async function does not unwind on+ // cancellation). Making `EntitlementSource.hasUnlockEntitlement()`+ // `throws` would leak this stamp from `outstandingScanObservations`+ // forever and strand `.loading`. outstandingScanObservations.remove(observation)- defer { promotePendingLoadingFallbackIfUnblocked() } // A cancelled scan reports `false` from a truncated read of the // entitlement sequence. A partial read is not evidence of "no- // entitlement", so it must never commit `.locked` (T-1868).- guard !Task.isCancelled else { return }+ // entitlement", so it must never commit `.locked` (T-1868) — and for+ // the same reason it has no finding of its own to report, so it falls+ // back to whatever is already known, after promoting anything its own+ // removal just unblocked.+ guard !Task.isCancelled else {+ promotePendingLoadingFallbackIfUnblocked()+ return entitlementState+ } - commitEntitlement(hasUnlock ? .unlocked : .locked, observedAt: observation)+ let result: EntitlementState = hasUnlock ? .unlocked : .locked+ commitEntitlement(result, observedAt: observation)+ promotePendingLoadingFallbackIfUnblocked()++ // Read `entitlementState` back only when something at or after this+ // scan's own observation actually published (T-2237, see doc comment):+ // a newer writer that has merely STAMPED — a newer scan still+ // suspended — leaves the shared property staler than this scan's own+ // finding, and returning it would be the very false negative this+ // fix exists to remove.+ return lastCommittedObservation >= observation ? entitlementState : result } // MARK: - Internal@@ -437,10 +556,15 @@ final class StoreManager { } pendingLoadingFallback = nil entitlementState = next+ lastCommittedObservation = max(lastCommittedObservation, observation) return } pendingLoadingFallback = nil if entitlementState != next { entitlementState = next }+ // The watermark records that this observation's evidence is what+ // `entitlementState` now reflects, so it advances even when the value+ // was already equal — the commit still won (T-2237).+ lastCommittedObservation = max(lastCommittedObservation, observation) } /// Promotes a held-back scan result once every newer scan that was@@ -452,15 +576,23 @@ final class StoreManager { /// committing, so this is the sole path here; /// `cancelledScanDoesNotStrandLoading` pins it. ///- /// This runs from `verifyEntitlements`'s `defer`, so it only fires when a- /// blocking scan RETURNS. A scan that never returns keeps its stamp- /// outstanding and the fallback held — `hasUnlockEntitlement()` has no- /// timeout, so that case still sits at `.loading`, where pre-T-2152 code- /// would have published the older scan's answer.+ /// `verifyEntitlements` calls this explicitly on each of its return paths,+ /// AFTER its own commit attempt and before it reads `entitlementState` for+ /// its return value, so it only fires when a blocking scan RESOLVES+ /// (committed or not — including cancellation). The ordering relative to+ /// the commit is load-bearing in both directions: running it earlier+ /// publishes a held-back older reading that then blocks the resolving+ /// scan's own newer commit, and running it from a `defer` is too late to+ /// affect the return value. See the comment at that call site. A scan that+ /// never resolves keeps its stamp outstanding and the fallback held —+ /// `hasUnlockEntitlement()` has no timeout, so that case still sits at+ /// `.loading`, where pre-T-2152 code would have published the older scan's+ /// answer. private func promotePendingLoadingFallbackIfUnblocked() { guard entitlementState == .loading, let fallback = pendingLoadingFallback else { return } guard !outstandingScanObservations.contains(where: { $0 > fallback.observation }) else { return } entitlementState = fallback.state+ lastCommittedObservation = max(lastCommittedObservation, fallback.observation) pendingLoadingFallback = nil }
diff --git a/prism/Services/AppStoreSync.swift b/prism/Services/AppStoreSync.swiftnew file mode 100644index 00000000..4ac46c4c--- /dev/null+++ b/prism/Services/AppStoreSync.swift@@ -0,0 +1,28 @@+import Foundation+import StoreKit++/// Performs the App Store re-sync that `StoreManager.restorePurchases()` runs+/// before its verification scan.+///+/// Extracted from `AppStore.sync()` for the same reason `EntitlementSource`+/// was extracted from `Transaction.currentEntitlements` (T-1868): with the+/// call welded to StoreKit, no unit test could drive `restorePurchases()` at+/// all, so its one-line wiring — forwarding `verifyEntitlements()`'s return+/// value rather than re-reading `entitlementState` — was unpinned and could be+/// reverted with the whole suite still green (T-2237).+protocol AppStoreSyncing: Sendable {+ /// Re-syncs the App Store account, throwing so the caller can surface a+ /// user-visible error (req 6.4 / 7.2).+ func sync() async throws+}++/// Production `AppStoreSyncing` backed by `AppStore.sync()`.+///+/// `nonisolated` for the same reason as `StoreKitEntitlementSource`: it is a+/// default argument on `StoreManager`'s initialisers, and a default-argument+/// expression is evaluated in the caller's (nonisolated) context.+nonisolated struct StoreKitAppStoreSync: AppStoreSyncing {+ func sync() async throws {+ try await AppStore.sync()+ }+}
diff --git a/prism/Views/PaywallSheet.swift b/prism/Views/PaywallSheet.swiftindex 9f043555..0c7f44a1 100644--- a/prism/Views/PaywallSheet.swift+++ b/prism/Views/PaywallSheet.swift@@ -154,6 +154,19 @@ struct PaywallSheet: View { Task { await performRestore() } } .font(.footnote)+ // Two reasons, matching the guard added alongside this one on the+ // Settings Restore row (T-2237):+ // - `.loading` means the bootstrap scan hasn't landed yet, so a+ // cancelled scan could return `.loading` and this sheet would+ // render it as "No purchases to restore"+ // (`StoreManager.verifyEntitlements()` names this guard in its+ // contract).+ // - `isPurchaseInFlight` is the same guard the unlock button above+ // already carries; without it Restore stays tappable during a+ // purchase, and `performRestore()` would overwrite that purchase's+ // `purchaseState`. T-2186 owns serialising overlapping StoreKit+ // operations properly, at the manager boundary.+ .disabled(isPurchaseInFlight || storeManager.entitlementState == .loading) .accessibilityLabel(LocalizedStringKey("Restore previous purchases")) }
diff --git a/prism/Settings/SettingsView.swift b/prism/Settings/SettingsView.swiftindex 2412d1f4..3d68e6e8 100644--- a/prism/Settings/SettingsView.swift+++ b/prism/Settings/SettingsView.swift@@ -436,6 +436,7 @@ struct SettingsView: View { Button("Restore Purchases") { Task { await performRestore() } }+ .disabled(storeManager.entitlementState == .loading) .accessibilityLabel(LocalizedStringKey("Restore previous purchases")) } header: { Text("Support Prism")
diff --git a/prismTests/StoreManagerTests.swift b/prismTests/StoreManagerTests.swiftindex d623a0b6..18180e0e 100644--- a/prismTests/StoreManagerTests.swift+++ b/prismTests/StoreManagerTests.swift@@ -441,6 +441,53 @@ struct StoreManagerTests { #expect(store.entitlementState == .unlocked) } + @Test("A cancelled scan's own return value reflects a promotion its removal unblocked")+ @MainActor+ func cancelledScanReturnsPromotedResultInsteadOfStaleLoading() async {+ // Round-2 review follow-up (T-2237): the cancellation early-return+ // used to read `entitlementState` before `promotePendingLoadingFallbackIfUnblocked()`+ // ran (it fired from a `defer`, which executes after the return value+ // is already captured). So a cancelled scan whose own removal from+ // `outstandingScanObservations` was the last thing blocking an older+ // scan's held-back result still handed its CALLER a stale `.loading`,+ // even though `entitlementState` itself was correctly promoted a+ // moment later. `restorePurchases()` forwards that return value+ // verbatim, so this is the exact shape that could resurface "No+ // purchases to restore" for a `.loading` read.+ let source = ScriptedEntitlementSource(results: [true, false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .loading,+ entitlementSource: source+ )++ let first = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let second = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ // Second scan is cancelled before it ever reads the world.+ second.cancel()++ // First scan reads `true`, but the second (newer) scan is still+ // outstanding, so its result is held back as `pendingLoadingFallback`+ // rather than committed directly.+ source.open(0)+ let firstResult = await first.value+ #expect(firstResult == .unlocked)++ // Releasing the second scan lets it resolve: it removes its own+ // (newer) stamp, which is the last thing blocking the held-back+ // result, so promotion fires. The cancellation guard's return must+ // observe that promoted value, not the `.loading` snapshot from+ // before promotion ran.+ source.open(1)+ let secondResult = await second.value++ #expect(secondResult == .unlocked)+ #expect(store.entitlementState == .unlocked)+ }+ // MARK: - T-2152: superseded scans must not publish while a newer scan // is still outstanding, even during the `.loading` bootstrap window. //@@ -516,10 +563,315 @@ struct StoreManagerTests { // commit. #expect(store.entitlementState == .unlocked) }++ // MARK: - T-2237: `verifyEntitlements()`, and `restorePurchases()` through+ // it, must report the FRESHEST KNOWN entitlement state, never a stale+ // reading. Before the first T-2237 fix, `restorePurchases()` read+ // `entitlementState` after the scan instead of the scan's own result, so+ // a genuine owner could be told "No purchases to restore" whenever this+ // scan's result was merely held back during the `.loading` bootstrap+ // window (T-2152). That fix over-corrected by always returning the+ // scan's OWN reading regardless of whether a newer writer (e.g. a+ // `Transaction.updates` delivery landing mid-scan) had already committed+ // something newer — reopening the same symptom in the opposite+ // direction. The tests below pin the corrected rule: return+ // `entitlementState` when something at or after this scan's own+ // observation has actually COMMITTED (`lastCommittedObservation`), and+ // this scan's own finding otherwise — a newer writer that has merely+ // stamped, such as a newer scan still suspended, leaves the shared+ // property staler than what this scan found.+ //+ // `restorePurchases()` itself is driveable since its `AppStore.sync()` was+ // put behind the `AppStoreSyncing` seam (T-2237), so its one-line+ // forwarding of the return value is pinned directly rather than by proxy.++ @Test("verifyEntitlements returns the newer committed state when its own scan is superseded")+ @MainActor+ func verifyEntitlementsReturnsCommittedStateWhenSuperseded() async {+ // The older scan (index 0) finds the unlock; a newer, concurrent scan+ // (index 1) finds nothing and — being newer — is the one that lands+ // in `entitlementState`. The older scan's RETURN VALUE must report+ // that newer, fresher truth, not its own stale reading.+ let source = ScriptedEntitlementSource(results: [true, false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ let older = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let newer = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ source.open(1)+ _ = await newer.value+ #expect(store.entitlementState == .locked)++ source.open(0)+ let olderResult = await older.value++ // The regression this pins: a version of `verifyEntitlements()` that+ // returns its own scan's snapshot regardless of the write outcome+ // would report `.unlocked` here even though `.locked` is the newer,+ // winning truth.+ #expect(olderResult == .locked)+ #expect(store.entitlementState == .locked)+ }++ @Test("A restore scan suspended during a winning transaction event returns the transaction's state, not its own stale reading")+ @MainActor+ func verifyEntitlementsReturnsTransactionStateWhenItWinsDuringSuspension() async {+ // The exact T-2237 regression scenario: a restore scan reads the+ // pre-purchase world (no unlock) and is held suspended while a+ // `Transaction.updates` delivery lands and commits `.unlocked` —+ // newer evidence that wins the observation-clock race (T-1868). The+ // scan's own snapshot is `.locked`; its RETURN VALUE must still+ // report the transaction's newer `.unlocked`, or `restorePurchases()`+ // tells a paying customer "No purchases to restore" at the exact+ // moment they became unlocked.+ let source = ScriptedEntitlementSource(results: [false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ let scan = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)++ store.applyTransactionEvent(unlocked: true)+ #expect(store.entitlementState == .unlocked)++ source.open(0)+ let result = await scan.value++ // Before the fix: the scan's own stale `.locked` reading was+ // returned regardless of the winning transaction event.+ #expect(result == .unlocked)+ #expect(store.entitlementState == .unlocked)+ }++ @Test("verifyEntitlements returns its own finding even while held back behind a still-outstanding newer scan")+ @MainActor+ func verifyEntitlementsReturnsOwnFindingWhileHeldBackDuringLoading() async {+ // T-2152's `pendingLoadingFallback` mechanism holds A's result back+ // while B is still outstanding, so `entitlementState` stays `.loading`+ // until B resolves. A's RETURN VALUE must not wait on that.+ //+ // This test drives `verifyEntitlements()` directly, so it does NOT+ // pin `restorePurchases()`'s own wiring — an earlier version of this+ // comment claimed it did. `restorePurchasesReportsItsOwnScanNotStaleSharedState`+ // below is the test that pins that.+ let source = ScriptedEntitlementSource(results: [true, false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .loading,+ entitlementSource: source+ )++ let scanA = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let scanB = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ source.open(0)+ let resultA = await scanA.value++ // Shared state is still withheld pending B.+ #expect(store.entitlementState == .loading)+ // Before the fix: `restorePurchases()` would read `entitlementState`+ // here — still `.loading` — and (since callers treat any non-+ // `.unlocked` result as failure) tell a genuine owner nothing was+ // found.+ #expect(resultA == .unlocked)++ source.open(1)+ _ = await scanB.value+ }++ @Test("verifyEntitlements returns .locked when its own scan genuinely finds nothing")+ @MainActor+ func verifyEntitlementsReturnsLockedWhenNothingFound() async {+ let source = ScriptedEntitlementSource(results: [false])+ source.open(0)+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ let result = await store.verifyEntitlements()++ #expect(result == .locked)+ #expect(store.entitlementState == .locked)+ }++ @Test("A held-back older result must not publish ahead of a newer scan's own commit")+ @MainActor+ func staleHeldBackResultDoesNotBeatNewerScanDuringBootstrap() async throws {+ // The F1 interleaving: three overlapping bootstrap scans, all during+ // `.loading`, whose NEWEST burns its stamp without committing.+ //+ // A(1) reads `false`, B(2) reads `true` (B is a restore), E(3) is+ // cancelled before it reads anything.+ //+ // E's cancellation leaves `latestEntitlementObservation` at 3 with+ // nothing committed — precisely T-2152's motivating case. A then+ // resolves and is held back behind still-outstanding B. When B+ // resolves, B's newer `.unlocked` must win.+ //+ // The regression this pins: promoting the held-back fallback BEFORE+ // `commitEntitlement` (rather than after it) publishes A's older+ // `.locked` first, which then makes `commitEntitlement(B, 2)` take the+ // stale branch — stamp 2 is not the latest, and `entitlementState` is+ // no longer `.loading` — and drop B's finding entirely. The stale+ // reading wins, the fresh one is lost, and B's restore reports "No+ // purchases to restore" to an owner who has one.+ let source = ScriptedEntitlementSource(results: [false, true, false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .loading,+ entitlementSource: source,+ appStoreSync: StubAppStoreSync()+ )++ let scanA = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let restoreB = Task { try await store.restorePurchases() }+ await source.waitForScanStart(2)+ let scanE = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(3)++ // E burns the newest stamp without ever committing.+ scanE.cancel()+ source.open(2)+ _ = await scanE.value++ // A resolves: still blocked by outstanding B, so it is held back.+ source.open(0)+ let resultA = await scanA.value+ #expect(resultA == .locked)+ #expect(store.entitlementState == .loading)++ // B resolves: its newer reading is the one that must publish, and the+ // one its own restore call must report.+ source.open(1)+ let resultB = try await restoreB.value++ #expect(resultB == .unlocked)+ #expect(store.entitlementState == .unlocked)+ }++ @Test("A scan whose only newer writer has merely stamped returns its own finding, not the older committed state")+ @MainActor+ func verifyEntitlementsReturnsOwnFindingWhenNewerScanHasOnlyStamped() async {+ // The F2 path, and the ticket's most plausible one: `AppStore.sync()`+ // presents an authentication sheet, which drives the scene through+ // inactive → active, and `prismApp` fires `verifyEntitlements()` on+ // `.active`. That newer scan STAMPS while this one is still suspended.+ //+ // Nothing newer has been WRITTEN, so `entitlementState` still holds+ // the older `.locked`. Outside `.loading`, `commitEntitlement` drops+ // this scan's `.unlocked` with no fallback — so reading the shared+ // property back returns something strictly OLDER than this scan's own+ // finding. `lastCommittedObservation` is what distinguishes "a newer+ // writer stamped" from "a newer value published".+ let source = ScriptedEntitlementSource(results: [true, true])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ let owner = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let newer = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ // The newer scan stays suspended: it has stamped, published nothing.+ source.open(0)+ let ownerResult = await owner.value++ #expect(ownerResult == .unlocked)+ #expect(store.entitlementState == .locked)++ source.open(1)+ _ = await newer.value+ #expect(store.entitlementState == .unlocked)+ }++ @Test("restorePurchases reports its own scan's finding, not the shared state a newer stamped scan left behind")+ @MainActor+ func restorePurchasesReportsItsOwnScanNotStaleSharedState() async throws {+ // Pins `restorePurchases()`'s own one-line wiring+ // (`return await verifyEntitlements()`). A revert to+ // `await verifyEntitlements(); return entitlementState` returns the+ // committed `.locked` here — the T-2237 false negative — and this+ // test goes red. Nothing else in the suite catches that revert.+ let source = ScriptedEntitlementSource(results: [true, true])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source,+ appStoreSync: StubAppStoreSync()+ )++ let restore = Task { try await store.restorePurchases() }+ await source.waitForScanStart(1)+ let newer = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ // The newer scan has stamped but published nothing, so the shared+ // state is still the pre-restore `.locked`.+ source.open(0)+ let restored = try await restore.value++ #expect(restored == .unlocked)+ #expect(store.entitlementState == .locked)++ source.open(1)+ _ = await newer.value+ }++ @Test("A cancelled bootstrap scan with nothing to promote returns .loading")+ @MainActor+ func cancelledScanDuringBootstrapReturnsLoading() async {+ // The one exit that does not satisfy the "never staler than my own+ // finding" contract, pinned honestly rather than left implicit: a+ // cancelled scan has no finding of its own, and during the bootstrap+ // window with nothing held back there is nothing to promote either,+ // so `.loading` reaches the caller. Both restore call sites render+ // that as "No purchases to restore", which is why they are+ // `.disabled` while `entitlementState == .loading` — see+ // `verifyEntitlements()`'s contract, which names that dependency.+ let source = ScriptedEntitlementSource(results: [false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .loading,+ entitlementSource: source+ )++ let scan = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ scan.cancel()+ source.open(0)+ let result = await scan.value++ #expect(result == .loading)+ #expect(store.entitlementState == .loading)+ } } // MARK: - Test doubles +/// `AppStoreSyncing` that succeeds immediately, so `restorePurchases()` can be+/// driven in a unit test without contacting StoreKit (T-2237). The interesting+/// interleavings all live in the verification scan that follows the sync.+private struct StubAppStoreSync: AppStoreSyncing {+ func sync() async throws {}+}+ /// `EntitlementSource` whose scans suspend until the test releases them, so a /// transaction event can be interleaved into a scan's suspension window — the /// interleaving that produced T-1868.
diff --git a/specs/inapp-purchase/decision_log.md b/specs/inapp-purchase/decision_log.mdindex a26ee7a3..5bbbcee2 100644--- a/specs/inapp-purchase/decision_log.md+++ b/specs/inapp-purchase/decision_log.md@@ -529,3 +529,109 @@ Putting the rule on `Catalog` rather than at the assignment is a testability con `StoreManager.fetchProducts()`, the new `Catalog`/`selectCatalog` pair, and `StoreProduct` (Q1). No view, string catalog, or entitlement code is touched; the T-1868 observation clock and the T-1779 presenter split are independent of `productLoadError`. Req 2.13 is amended in place, and the `fetchProducts()` block in `design.md` is updated to the shipped shape. ---++## Decision 15: A Verification Scan Reports the Freshest COMMITTED State, Judged by a Publication Watermark++**Date**: 2026-08-23+**Status**: accepted++### Context++Decision 11 gave `restorePurchases()` a return value so the UI could tell success from "nothing+to restore" from failure. It did not say where that value comes from, and the implementation read+the shared, app-wide `entitlementState` property after running `verifyEntitlements()`. Three+writers move that property (a completed purchase, the `Transaction.updates` listener, and a+verification scan), so a reader cannot attribute its value to its own call — which is T-2237: a+genuine owner told "No purchases to restore" while the entitlement was resolving correctly+underneath.++Returning the scan's own finding instead is wrong in the opposite direction. A+`Transaction.updates` delivery that lands while the scan is suspended carries newer evidence and+legitimately wins the T-1868 observation clock; reporting the scan's now-stale reading tells the+customer "no purchases" at the exact moment the transaction unlocked them. That shape shipped on+this branch and was reverted.++Neither answer is right on its own because the code recorded only who last STAMPED the observation+clock (`latestEntitlementObservation`), never who last PUBLISHED. A newer scan still suspended in+`hasUnlockEntitlement()` has stamped and written nothing — and outside `.loading`,+`commitEntitlement` drops the resolving scan's write with no fallback, leaving the shared property+strictly older than what that scan found.++### Decision++`verifyEntitlements()` returns `EntitlementState`, and `restorePurchases()` forwards it verbatim.+`StoreManager` records `lastCommittedObservation`, a watermark advanced wherever a value genuinely+reaches `entitlementState` (both committing branches of `commitEntitlement(_:observedAt:)` and+`promotePendingLoadingFallbackIfUnblocked()`). The return rule is:++```swift+return lastCommittedObservation >= observation ? entitlementState : result+```++— read the shared property back only when something at or after this call's own observation+actually published; otherwise report this scan's own finding.++### Rationale++The question a caller needs answered is "did anything at or after my own observation actually get+written?", and only a publication watermark can answer it. The stamp clock answers a different+question ("has anyone newer started?"), and using it as a proxy is precisely the confusion that+kept T-2237 open through two attempted fixes.++The watermark also subsumes, rather than special-cases, the `.loading` fallback an earlier shape+carried: a result held back during the bootstrap window has not published either, so the same+comparison already returns the scan's own finding there. One rule replaces a rule plus an+exception.++### Alternatives Considered++- **Return the shared `entitlementState` after the scan (the pre-T-2237 shape)**: simplest —+ Rejected: it is not this call's answer at all, which is the bug.+- **Return the scan's own finding unconditionally**: obvious per-call attribution — Rejected: it+ masks a `Transaction.updates` delivery that legitimately won the clock, reporting "locked" the+ instant the customer became unlocked. Shipped on this branch and reverted.+- **Read the property back, with a `.loading`-only fallback**: fixes the T-2152 held-back case —+ Rejected: `.loading` is one symptom of "nothing published", not the general condition. Outside+ `.loading` a newer stamped-but-uncommitted scan still made the read-back staler than the scan's+ own finding, which is arguably T-2237's most likely real path (`AppStore.sync()`'s auth sheet+ drives a scene-phase round trip that starts exactly such a scan).+- **Serialise restore against every other StoreKit operation**: removes the overlap rather than+ reporting through it — Rejected as the wrong layer for this ticket, and it is T-2186's job.++### Consequences++**Positive:**++- A restore can no longer report a state older than its own scan found — the T-2237 symptom is+ closed on every route, not just the T-2152 one.+- A concurrent purchase or transaction event that wins on recency is reported instead of masked.+- One rule instead of a rule plus a `.loading` exception, and the rule states what it means.++**Negative:**++- A genuinely newer scan that commits `.locked` makes a restore whose own scan found the unlock+ report failure. Defensible — that scan started later and saw a newer world — but it is a real+ behaviour, pinned by `verifyEntitlementsReturnsCommittedStateWhenSuperseded`.+- A cancelled scan has no finding of its own, so it still returns `entitlementState`, which during+ the bootstrap window can be `.loading` — a value both restore call sites render as "No purchases+ to restore". This is now stated in `verifyEntitlements()`'s contract and pinned by+ `cancelledScanDuringBootstrapReturnsLoading`, and is kept unreachable by the `.disabled` guards+ on both Restore controls rather than by the service itself.+- Both Restore controls are now disabled during the bootstrap window with no explanation shown,+ and are removed from the VoiceOver focus order while disabled. The window is one+ `Transaction.currentEntitlements` scan.+- A third StoreKit seam (`AppStoreSyncing`) exists purely so `restorePurchases()` can be driven by+ a test; without it, the one-line forwarding this decision is about could be reverted with the+ whole suite still green.++### Impact++`StoreManager.verifyEntitlements()`, `restorePurchases()`, `commitEntitlement(_:observedAt:)`,+`promotePendingLoadingFallbackIfUnblocked()`, the new `AppStoreSyncing` seam, and the Restore+controls in `SettingsView` and `PaywallSheet`. Refines Decision 11's return-value contract from+"the entitlement this call resolved" to "the freshest COMMITTED entitlement as of this call,+falling back to this call's own finding when nothing at or after it has committed". The T-1868+ordering rules and the T-2152 held-back mechanism are unchanged; this adds a watermark alongside+them.++---
diff --git a/CLAUDE.md b/CLAUDE.mdindex 368e4e38..d56d7e60 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -97,7 +97,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat ### In-App Purchase System 1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, and the export counter — everything scoped to the ACCOUNT. It owns no paywall presentation state: that is per-scene and lives on `PaywallPresenter` (T-1779). `productLoadError` means "the catalog did not return the unlock product", empty or merely partial — StoreKit omits identifiers it cannot resolve rather than failing, and testing `fetched.isEmpty` left a partial catalog with `unlockProduct` nil and the flag false, a combination neither the paywall nor the Settings row has a branch for, so both spun forever (T-1841). A missing individual TIP is tolerated: the unlock product gates the paywall, the tips only fill the tip jar, so the two are selected independently and neither short-circuits the other — an early return on a missing unlock silently emptied an already-unlocked user's tip jar. The rule lives on `StoreManager.Catalog.loadFailed`, not at the assignment in `fetchProducts()`, because that method assigns to `Product`-typed properties and StoreKit will not let a test construct a `Product` — with the rule at the assignment, reverting it to `fetched.isEmpty` left the whole suite green. The selection itself is the pure `selectCatalog(from:)` over the `StoreProduct` seam (Decision 14)-2. `EntitlementState` is tri-state (`.loading` / `.locked` / `.unlocked`); the paywall and Settings UI branch on it. All writes flow through one commit point ordered by a monotonic observation clock — recency of information wins, never completion order — because a verification scan suspends mid-read and MainActor isolation does not cover that reentrancy window (T-1868). The StoreKit scan sits behind the injectable `EntitlementSource` seam so the ordering rules are unit-testable+2. `EntitlementState` is tri-state (`.loading` / `.locked` / `.unlocked`); the paywall and Settings UI branch on it. All writes flow through one commit point ordered by a monotonic observation clock — recency of information wins, never completion order — because a verification scan suspends mid-read and MainActor isolation does not cover that reentrancy window (T-1868). The StoreKit scan sits behind the injectable `EntitlementSource` seam (and `AppStore.sync()` behind `AppStoreSyncing`) so the ordering rules are unit-testable. That clock records who last STAMPED, which is not who last PUBLISHED, so `verifyEntitlements()` answers its own caller through a second watermark, `lastCommittedObservation` — `lastCommittedObservation >= observation ? entitlementState : result`, i.e. the shared property only when something at or after this call's own observation actually committed, and this scan's own finding otherwise, because a newer scan that has merely stamped leaves that property STALER than the scan being answered, which is how `restorePurchases()` came to tell a genuine owner "No purchases to restore" (T-2237, Decision 15) 3. `ExportCounter` tracks the export count with iCloud KVS sync and a UserDefaults mirror; reconciles on init and on external KVS change notifications 4. `KeyValueStoreProtocol` abstracts `NSUbiquitousKeyValueStore` so unit tests can inject a stub 5. `PaywallPresenter` (`prism/Views/PaywallPresenter.swift`, `@Observable @MainActor`) is the PER-SCENE owner of `isPresented` and `pendingExportAction`. `MainContentView` holds one as `@State` per window and the macOS `Settings` scene holds its own; each attaches the shared `paywallPresentation(presenter:storeManager:settings:systemObserver:)` host, which owns the sheet and calls `handleDismiss(entitlementState:)`. Holding those fields on the app-wide `StoreManager` meant every window's sheet bound to one flag — the paywall presented and dismissed in all windows, a second blocked export overwrote the first window's retry, and a Settings-initiated unlock presented over a document window (T-1779). What must NOT move per-scene is the purchase signal: the split first narrowed a `didCompletePurchase` flag along with everything else, which regressed the cross-scene purchase (block in window A, unlock from the Settings window, dismiss A → A's export silently dropped) because entitlement is account-scoped while the flag was set on whichever presenter ran the purchase. That flag is gone; see 8
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..8e58a9b7 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Restore Purchases no longer tells a genuine owner "No purchases to restore" (T-2237). It read the shared, app-wide entitlement state after its own verification scan finished, rather than that scan's own finding — so a real owner could be told nothing was found whenever the scan's own result was still held back behind an outstanding newer scan. Restore now reports the freshest known entitlement state as of the call — a concurrent purchase or restore transaction that completes while the scan is in flight is no longer masked by the scan's own, now-stale, reading — falling back to the scan's own finding whenever no newer answer has actually been published yet, which includes the case where a second check has started but not finished. That second case is the likelier one in practice: restoring can itself prompt for authentication, and returning to Prism afterwards starts a fresh check, which used to be enough to make the restore report nothing. The Restore controls in Settings and on the paywall are also disabled while entitlement status is still loading, so they cannot be tapped exactly when they are least able to answer, and the paywall's is disabled while a purchase is in flight, matching the Unlock button above it. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.
make lint (0 violations, 556 files), make verify-test-isolation (43/43 OK), and xcodebuild test -only-testing:prismTests/StoreManagerTests on the macOS destination: total=36 passed=36 failed=0, confirmed through Tools/check-test-results.sh so the non-zero test count is verified rather than assumed. That run compiled the whole scheme in Debug for macOS, so the macOS build is green by construction. The full suite, the iOS build, and make test-ui were not run — out of scope for this audit and gated by a shared test lock.
I attempted to run the mutation that deletes try await appStoreSync.sync() but the shared test lock was held by another agent for the full timeout; the source file was restored and the tree verified clean. That particular finding (M1) does not need an empirical run — StubAppStoreSync is a no-op with no recorded state and no suspension point, so removing the call is observationally identical and the suite must stay green. The other mutation claims (watermark revert, promotion hoist, defer restore) were traced statically rather than executed; the author reports having executed three of them.
If hasUnlockEntitlement() never returns, its stamp stays in outstandingScanObservations forever, where — being greater than any fallback observation — it permanently vetoes promotePendingLoadingFallbackIfUnblocked, one entry per hung foreground scan. Documented at :586-590, not introduced here, and Transaction.currentEntitlements has no timeout. Worth a ticket eventually, not this one.
PaywallSheet's new guard folds in isPurchaseInFlight because otherwise performRestore() would overwrite an in-flight purchase's purchaseState. That is a view-layer patch over a manager-layer problem, and the comment says so and names T-2186 as its owner. Right call for this ticket; make sure T-2186 actually exists and references this.