PR #360 — a verification scan of Transaction.currentEntitlements could commit a pre-purchase snapshot over a just-completed unlock, silently re-locking a paying user. The fix orders every write to entitlementState by a monotonic observation clock: recency of information wins, never completion order. CI is billing-blocked; all validation is local.
@MainActor only serialises synchronous regions — verifyEntitlements() suspends at its await, reopening the actor to purchase() and the Transaction.updates listener, so completion order decided the final entitlementState.UInt64 observation clock; every writer stamps when it gathers evidence, and commitEntitlement(_:observedAt:) drops any write whose stamp is no longer the latest.Task.isCancelled after the await), and any write is accepted while state is still .loading so a cancelled stamp-burner cannot strand the state there.EntitlementSource seam makes the interleaving expressible in unit tests for the first time — the reason this bug survived every prior review.report.md brought up to date with the review commit (19 tests, eight regression tests, the .loading exception) and one line added to CLAUDE.md's IAP section.solution-comparison.md.Ready to push
The concurrency design is sound: I walked every interleaving of the observation clock, including the .loading bootstrap exception, and the invariant holds — a transaction event commits synchronously under its own stamp, so the exception can never resurrect a superseded value. Eight deterministic regression tests pin the failure interleavings and the guard behaviours; all 19 StoreManagerTests pass locally and SwiftLint reports zero violations. The only findings were documentation staleness (the bugfix report predated the review commit), fixed in the working tree during this review.
6d8f64d T-1868: Add investigation report and failing regression tests for entitlement ordering 2098831 T-1868: Order entitlement writes by observation recency b06e89a T-1868: Add the cancellation guard, changelog, and bugfix report 0b1793c Address review: accept commits while entitlementState is .loading, cover cancellation paths working-tree Fixes applied in this review The app sells one unlock that removes the export limit. To know whether you own it, it asks Apple's App Store — at launch and on every return to the foreground — and that question takes a moment to answer. If you completed your purchase while a question was still in flight, the old answer arrived afterwards, said "no purchase found" (true when it was asked, false now), and the app believed it: the paywall came back right after you paid.
A paying customer was silently re-locked and the export the purchase was meant to unblock was lost. The reverse ordering could hide a refund. Both are now impossible: every answer carries a number recording when the app started gathering it, and when answers disagree, the one gathered later wins.
entitlementState: .loading, .locked, or .unlocked.prism/Services/EntitlementSource.swift (new) — protocol + StoreKitEntitlementSource wrapping Transaction.currentEntitlements, making the scan injectable.prism/Services/StoreManager.swift — latestEntitlementObservation: UInt64, beginEntitlementObservation(), single commit point commitEntitlement(_:observedAt:), event funnel applyTransactionEvent(unlocked:); verifyEntitlements() stamps before its await and refuses to commit a cancelled scan's read.prismTests/StoreManagerTests.swift — eight interleaving tests over a gated ScriptedEntitlementSource double.@MainActor isolation covers synchronous regions only; the scan's await reopens the actor to the other two writers. The fix orders writes by evidence recency: scans stamp before suspending, events stamp synchronously as they commit, and stale-stamped commits are dropped. Two guards: a cancelled scan's truncated sequence read is not evidence of "no entitlement" and commits nothing; and while state is .loading any write is accepted, because .loading carries zero information and a cancelled stamp-burner must not veto an earlier scan's genuine read.
Three independent implementations were compared: a two-counter guard (correct, partly redundant), an owned EntitlementLedger with single-use scan permits (most rigorous but a new 122-line type that turns entitlementState into a computed property, risky for Observation-driven UI), and this single clock — the smallest change that states the rule once. Chained-Task serialisation and a separate actor were rejected: neither orders a read-modify-write spanning a suspension.
Invariant: entitlementState reflects the write with the most recent evidence, never the last completer. Stamp allocation is increment-then-return on the MainActor, so it is totally ordered. The commit guard is observation == latestEntitlementObservation || entitlementState == .loading. The .loading exception is sound because only scans can burn a stamp without committing — applyTransactionEvent stamps and commits in one synchronous MainActor region — so if state is still .loading, no event has ever committed and nothing can be resurrected; a newer in-flight scan still owns the latest stamp and overwrites the bootstrap value on landing. The Task.isCancelled guard sits after the await, the only placement that observes the flag that truncated the read.
Public surface unchanged: verifyEntitlements() keeps its shape, entitlementState stays a stored private(set) property, prismApp.swift untouched. applyTransactionEvent is internal (not private) so tests can interleave an authoritative event into a scan's suspension window — it is also the production funnel from updateEntitlement(from:), so not test-only API. The clock matches the codebase's per-subsystem generation-counter idiom (parseGeneration, loadGeneration, renderGeneration); no shared abstraction warranted.
restorePurchases() can be superseded by a foreground scan starting after AppStore.sync() — correct, the later scan reads the post-sync world.@unchecked Sendable over NSLock; all mutable state is accessed inside lock.withLock and continuations resume outside the lock — no lost-wakeup path.prism/Services/StoreManager.swift
Why it matters. The heart of the fix. Correctness of every purchase/restore/refund flow now rests on this single commit guard, so the reviewer should walk the interleavings against it.
What to look at. StoreManager.swift — latestEntitlementObservation, beginEntitlementObservation(), commitEntitlement(_:observedAt:), applyTransactionEvent(unlocked:)
prism/Services/StoreManager.swift
Why it matters. The one deliberate hole in the staleness guard — the subtlest line in the diff. If it were wrong, a stale scan could resurrect superseded state; if it were absent, a cancelled scan could strand the app at .loading and wedge every gated export.
What to look at. StoreManager.swift:321-326 — guard observation == latestEntitlementObservation || entitlementState == .loading
prism/Services/StoreManager.swift
Why it matters. A cancelled scan truncates the for-await over the entitlement sequence, leaving hasUnlock == false — committing that would report 'no entitlement' from a partial read (defect D3).
What to look at. StoreManager.swift:230-245 — verifyEntitlements()
prism/Services/EntitlementSource.swift
Why it matters. Defect D4 — the scan was welded to Transaction.currentEntitlements, so the failing interleaving could not be written as a test. That, not subtlety, is why the race survived every prior review.
What to look at. EntitlementSource.swift:1-37 (new file)
prismTests/StoreManagerTests.swift
Why it matters. Eight new tests pin the three failure interleavings (stale-scan-vs-purchase, stale-scan-vs-revocation, older-vs-newer scan) and five guard behaviours (uncontended commit, sequential commits, post-event scan, cancelled read, .loading bootstrap) — with no timing dependence.
What to look at. StoreManagerTests.swift:141-320 tests, 424-495 ScriptedEntitlementSource
specs/bugfixes/concurrent-storekit-verification-race/report.md
Why it matters. The bugfix report predated the review commit: it said 17 tests and listed six regression tests, while HEAD has 19 and eight, and the .loading exception was undocumented. Stale spec docs are worse than none.
What to look at. report.md test list, counts, and commitEntitlement bullet; CLAUDE.md IAP section item 2; implementation.md added
Three implementations were built independently from the same red baseline. The two-counter design was correct but redundant once events also stamp a shared clock; the EntitlementLedger/ScanPermit design made stale writes unrepresentable but cost a new 122-line type and converted entitlementState to a computed property, widening the blast radius on Observation-driven UI. The single clock is the smallest change that fully closes the defect, stating the ordering rule once at one commit point. Recorded in solution-comparison.md.
A scan cancelled after burning the latest stamp never commits; without the exception it would veto an earlier scan's genuine read and strand the state at .loading, wedging every gated export until the next writer event. Sound because only scans can burn a stamp without committing — transaction events stamp and commit in one synchronous MainActor region, so state still being .loading proves no event has ever committed.
Tests must interleave an authoritative transaction event into a scan's suspension window, and StoreKit cannot deliver real transactions in unit tests. The method is also the production funnel from updateEntitlement(from:), so it is not test-only API grafted onto the type.
Transaction.currentEntitlements only yields already-finished transactions, so finish() calls stay in the Transaction.updates listener (inapp-purchase req 2.3/2.9). Preserved verbatim from the pre-fix code and now documented on StoreKitEntitlementSource.
Cancellation truncates the for await over the entitlement sequence, leaving the accumulator at false — a partial read is not evidence of "no entitlement" and must never commit .locked (defect D3). The residual — state possibly staying .loading until the next writer event when the first scan is cancelled — is handled by the .loading exception; the concrete-state variant is accepted as self-correcting.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | specs/bugfixes/.../report.md | Report was stale relative to HEAD: claimed 'all 17 tests pass' and listed six regression tests, but the review commit 0b1793c added two cancellation tests (19 total, eight for T-1868) and the .loading bootstrap exception, none of which the report described. | Updated the test list (added cancelledVerificationDoesNotCommit and cancelledScanDoesNotStrandLoading), corrected both counts to 19, and documented the .loading exception in the commitEntitlement bullet. |
| minor | CLAUDE.md In-App Purchase System | The IAP architecture section did not mention the new write-ordering discipline on entitlementState or the injectable EntitlementSource seam — the kind of non-obvious invariant a future session would re-derive the hard way. | Added one sentence to item 2: all writes flow through a single observation-clock-ordered commit point, with the StoreKit scan behind the injectable EntitlementSource seam. |
| nit | prism/Services/EntitlementSource.swift | StoreKitEntitlementSource uses `guard case .verified` directly instead of StoreManager.checkVerified. Considered, not worth unifying: the source is a nonisolated Sendable struct, checkVerified is a throwing MainActor method, and the shared pattern is two lines. | Left as is — reuse across the isolation boundary would cost more than the duplication. |
| nit | prism/Services/EntitlementSource.swift | hasUnlockEntitlement iterates the full currentEntitlements sequence without breaking early once the unlock is found — same behaviour as the pre-fix code. For a non-consumable the sequence yields at most one entry per product, so an early exit saves nothing measurable. | Left as is — not worth changing, and keeping the loop shape identical to the replaced code makes the refactor auditable. |
| nit | StoreManager staleness residual | A scan cancelled after burning the latest stamp while state is a concrete value drops an earlier scan's genuine result; state keeps its previous value until the next scan or event. Self-correcting, and production scans are only cancelled at StoreManager deinit. | Accepted — documented in the code comment, the report, and implementation.md rather than guarded, since tracking commit-vs-burn per stamp would reintroduce the complexity the single-clock design avoids. |
Click to expand.
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex 24fdd61..06a1693 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -85,6 +85,32 @@ final class StoreManager { @ObservationIgnored private var fetchProductsTask: Task<Void, Never>? + /// Backing store for verification scans. Injectable so tests can control+ /// a scan's suspension window (T-1868).+ @ObservationIgnored+ private let entitlementSource: EntitlementSource++ /// 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).+ ///+ /// `StoreManager` is `@MainActor`, but that only serialises *synchronous*+ /// regions. `verifyEntitlements()` is `async` and suspends at its `await`,+ /// which reopens the actor to the two other writers of `entitlementState`+ /// (a completed `purchase(_:)` and the `Transaction.updates` listener,+ /// both via `applyTransactionEvent`). A scan therefore reads the world at+ /// time T and, unless guarded, commits that stale snapshot at time T+n —+ /// so completion order decided the winner and a pre-purchase scan could+ /// re-lock a user who had just paid.+ ///+ /// Every writer stamps the moment it gathers its evidence by bumping this+ /// clock, and `commitEntitlement(_:observedAt:)` applies a write only while+ /// its stamp is still the latest. A scan whose evidence has since been+ /// superseded (by a later scan or a transaction event) is dropped rather+ /// than committed — stale information never wins, not even transiently.+ @ObservationIgnored+ private var latestEntitlementObservation: UInt64 = 0+ // MARK: - Init convenience init() {@@ -99,8 +125,12 @@ final class StoreManager { } } - init(exportCounter: ExportCounter) {+ init(+ exportCounter: ExportCounter,+ entitlementSource: EntitlementSource = StoreKitEntitlementSource()+ ) { self.exportCounter = exportCounter+ self.entitlementSource = entitlementSource transactionListener = listenForTransactions() initTask = Task { [weak self] in guard let self else { return }@@ -112,8 +142,13 @@ final class StoreManager { /// Test-only initialiser that seeds state without contacting StoreKit. /// Used by unit tests to validate gate logic in isolation.- init(exportCounter: ExportCounter, entitlementState: EntitlementState) {+ init(+ exportCounter: ExportCounter,+ entitlementState: EntitlementState,+ entitlementSource: EntitlementSource = StoreKitEntitlementSource()+ ) { self.exportCounter = exportCounter+ self.entitlementSource = entitlementSource self.entitlementState = entitlementState } @@ -192,20 +227,21 @@ final class StoreManager { } /// Re-checks entitlements after foreground transitions or other events.- /// `Transaction.currentEntitlements` returns transactions that are- /// already finished, so no `finish()` is needed here — those calls- /// belong in the `Transaction.updates` listener (req 2.3, 2.9). func verifyEntitlements() async {- var hasUnlock = false- for await result in Transaction.currentEntitlements {- guard let transaction = try? checkVerified(result) else { continue }- if transaction.productID == ProductID.unlock,- transaction.revocationDate == nil {- hasUnlock = true- }- }- let next: EntitlementState = hasUnlock ? .unlocked : .locked- if entitlementState != next { entitlementState = next }+ // 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+ // stamp captures when this scan began reading the world; the commit+ // below is dropped if newer evidence has arrived since.+ let observation = beginEntitlementObservation()+ let hasUnlock = await entitlementSource.hasUnlockEntitlement()++ // 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 }++ commitEntitlement(hasUnlock ? .unlocked : .locked, observedAt: observation) } // MARK: - Internal@@ -243,7 +279,49 @@ final class StoreManager { private func updateEntitlement(from transaction: Transaction) { guard transaction.productID == ProductID.unlock else { return }- let next: EntitlementState = transaction.revocationDate == nil ? .unlocked : .locked+ applyTransactionEvent(unlocked: transaction.revocationDate == nil)+ }++ /// Commits entitlement state from an authoritative transaction event — a+ /// completed purchase or a `Transaction.updates` delivery. Such an event+ /// describes the world at the moment it was generated, so it always+ /// carries newer truth than a verification scan that started earlier.+ func applyTransactionEvent(unlocked: Bool) {+ // T-1868: a transaction event runs synchronously on the MainActor and+ // reports the world as of right now, so it is by definition the newest+ // evidence. Take a fresh observation stamp — which also invalidates any+ // in-flight scan that started earlier — and commit under it.+ let observation = beginEntitlementObservation()+ commitEntitlement(unlocked ? .unlocked : .locked, observedAt: observation)+ }++ /// Bumps the observation clock and returns the new value, marking the+ /// caller as the most recent gatherer of entitlement evidence (T-1868).+ private func beginEntitlementObservation() -> UInt64 {+ latestEntitlementObservation += 1+ return latestEntitlementObservation+ }++ /// Applies an entitlement write only while its `observation` stamp is still+ /// the latest one issued. If a newer writer has begun since (a later scan+ /// or a transaction event), `observation` is stale and the write is dropped+ /// — recency of information decides the winner, never completion order+ /// (T-1868, actor reentrancy).+ ///+ /// One exception: while `entitlementState` is still `.loading`, any write+ /// is accepted. `.loading` carries zero information, so no stamp can be+ /// staler than it — and without this, a writer that takes the latest stamp+ /// but never commits (a scan cancelled mid-read) would veto every earlier+ /// writer's successful read and strand the state at `.loading` until the+ /// next writer event. A newer in-flight scan still wins afterwards: its+ /// stamp is the latest, so its commit overwrites this bootstrap value.+ /// Only scans can burn a stamp without committing (transaction events+ /// stamp and commit synchronously), so the exception can never resurrect+ /// a value a transaction event has superseded.+ private func commitEntitlement(_ next: EntitlementState, observedAt observation: UInt64) {+ guard observation == latestEntitlementObservation || entitlementState == .loading else {+ return+ } if entitlementState != next { entitlementState = next } }
diff --git a/prism/Services/EntitlementSource.swift b/prism/Services/EntitlementSource.swiftnew file mode 100644index 0000000..a8fcd84--- /dev/null+++ b/prism/Services/EntitlementSource.swift@@ -0,0 +1,37 @@+import Foundation+import StoreKit++/// Supplies the current unlock entitlement for a `StoreManager` verification+/// scan.+///+/// Extracted from `Transaction.currentEntitlements` so the ordering rules in+/// `StoreManager.verifyEntitlements()` can be exercised deterministically: a+/// test source can hold a scan suspended while a transaction event lands,+/// which is precisely the interleaving that produced T-1868 and which no test+/// could express while the scan was welded to StoreKit.+protocol EntitlementSource: Sendable {+ /// Returns `true` when a verified, non-revoked unlock entitlement exists.+ ///+ /// Implementations suspend, so callers must assume entitlement state can+ /// be mutated by another writer while this runs.+ func hasUnlockEntitlement() async -> Bool+}++/// Production `EntitlementSource` backed by `Transaction.currentEntitlements`.+///+/// `currentEntitlements` only yields transactions that are already finished,+/// so no `finish()` call belongs here — those stay in the+/// `Transaction.updates` listener (req 2.3, 2.9).+struct StoreKitEntitlementSource: EntitlementSource {+ func hasUnlockEntitlement() async -> Bool {+ var hasUnlock = false+ for await result in Transaction.currentEntitlements {+ guard case .verified(let transaction) = result else { continue }+ if transaction.productID == StoreManager.ProductID.unlock,+ transaction.revocationDate == nil {+ hasUnlock = true+ }+ }+ return hasUnlock+ }+}
diff --git a/prismTests/StoreManagerTests.swift b/prismTests/StoreManagerTests.swiftindex 9e1d6be..b985588 100644--- a/prismTests/StoreManagerTests.swift+++ b/prismTests/StoreManagerTests.swift@@ -130,4 +130,283 @@ struct StoreManagerTests { #expect(store.remainingFreeExports == 0) }++ // MARK: - Entitlement ordering (T-1868)+ //+ // `StoreManager` is MainActor-isolated, but a verification scan suspends+ // partway through, which reopens the actor to the purchase flow and the+ // `Transaction.updates` listener. These tests pin the rule that decides+ // the winner: recency of information, never completion order.++ @Test("A verification scan started before a purchase cannot overwrite it")+ @MainActor+ func staleVerificationDoesNotOverwritePurchase() async {+ // The scan reads the world as it was before the purchase — no unlock —+ // and is held suspended while the purchase lands.+ 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)+ await scan.value++ // Expected: the purchase is the newer truth, so it stands.+ // Before the fix: the scan's pre-purchase snapshot commits `.locked`+ // over it, re-locking a user who has just paid.+ #expect(store.entitlementState == .unlocked)+ }++ @Test("A verification scan started before a revocation cannot re-grant the unlock")+ @MainActor+ func staleVerificationDoesNotOverwriteRevocation() async {+ // The inverse ordering: the scan still sees the entitlement that the+ // in-flight revocation is about to remove.+ let source = ScriptedEntitlementSource(results: [true])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .unlocked,+ entitlementSource: source+ )++ let scan = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)++ store.applyTransactionEvent(unlocked: false)+ #expect(store.entitlementState == .locked)++ source.open(0)+ await scan.value++ // Expected: the revocation stands.+ // Before the fix: the scan restores `.unlocked` after a refund.+ #expect(store.entitlementState == .locked)+ }++ @Test("An older verification scan cannot overwrite a newer scan's result")+ @MainActor+ func olderVerificationDoesNotOverwriteNewerVerification() async {+ // Models restore-vs-foreground: scan 0 read the pre-`AppStore.sync()`+ // world, scan 1 read the post-sync world that found the purchase.+ let source = ScriptedEntitlementSource(results: [false, true])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ let first = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)+ let second = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(2)++ source.open(1)+ await second.value+ #expect(store.entitlementState == .unlocked)++ source.open(0)+ await first.value++ // Expected: the later scan read a later world, so it wins.+ // Before the fix: the earlier scan finishes last and wins.+ #expect(store.entitlementState == .unlocked)+ }++ @Test("An uncontended verification scan still commits its result")+ @MainActor+ func uncontendedVerificationCommits() async {+ // Positive control: the ordering guard must not block the normal path.+ let source = ScriptedEntitlementSource(results: [true])+ source.open(0)+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ await store.verifyEntitlements()++ #expect(store.entitlementState == .unlocked)+ }++ @Test("Sequential verification scans each commit — the guard does not wedge")+ @MainActor+ func sequentialVerificationsEachCommit() async {+ // Positive control: a guard keyed on a generation counter must reset+ // between scans, or the first commit would freeze all later ones.+ let source = ScriptedEntitlementSource(results: [true, false])+ source.open(0)+ source.open(1)+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ await store.verifyEntitlements()+ #expect(store.entitlementState == .unlocked)++ await store.verifyEntitlements()+ #expect(store.entitlementState == .locked)+ }++ @Test("A verification scan started after a transaction event still commits")+ @MainActor+ func verificationAfterTransactionEventCommits() async {+ // A transaction event must not permanently veto later scans: the scan+ // below starts after the event, so it carries the newer reading.+ let source = ScriptedEntitlementSource(results: [false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .locked,+ entitlementSource: source+ )++ store.applyTransactionEvent(unlocked: true)+ source.open(0)++ await store.verifyEntitlements()++ #expect(store.entitlementState == .locked)+ }++ @Test("A cancelled scan does not commit its truncated read")+ @MainActor+ func cancelledVerificationDoesNotCommit() async {+ // D3: a cancelled scan's `false` comes from a truncated read of the+ // entitlement sequence, which is not evidence of "no entitlement".+ // The scan below holds the LATEST stamp, so only the cancellation+ // guard stands between its truncated read and a wrongful `.locked`.+ let source = ScriptedEntitlementSource(results: [false])+ let store = StoreManager(+ exportCounter: makeCounter(),+ entitlementState: .unlocked,+ entitlementSource: source+ )++ let scan = Task { await store.verifyEntitlements() }+ await source.waitForScanStart(1)++ scan.cancel()+ source.open(0)+ await scan.value++ // Expected: the truncated read commits nothing; the unlock survives.+ #expect(store.entitlementState == .unlocked)+ }++ @Test("A cancelled later scan cannot strand entitlementState at .loading")+ @MainActor+ func cancelledScanDoesNotStrandLoading() async {+ // The cancelled scan burns the latest stamp without ever committing.+ // That must not veto the earlier scan's successful read: `.loading`+ // carries zero information, so a superseded-but-real result beats it.+ 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.cancel()+ source.open(1)+ await second.value++ source.open(0)+ await first.value++ // Expected: the first scan's genuine read commits despite its stale+ // stamp. Before the fix: it is dropped and the state stays `.loading`+ // until the next writer event, wedging every gated export.+ #expect(store.entitlementState == .unlocked)+ }+}++// MARK: - Test doubles++/// `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.+///+/// Scan *n* (zero-based, in start order) suspends until `open(n)` and then+/// reports `results[n]`. `open` may be called before the scan starts.+private final class ScriptedEntitlementSource: EntitlementSource, @unchecked Sendable {+ private let lock = NSLock()+ private let results: [Bool]+ private var nextScanIndex = 0+ private var startedScans = 0+ private var openedGates: Set<Int> = []+ private var gateWaiters: [Int: CheckedContinuation<Void, Never>] = [:]+ private var startWaiters: [(target: Int, continuation: CheckedContinuation<Void, Never>)] = []++ init(results: [Bool]) {+ self.results = results+ }++ func hasUnlockEntitlement() async -> Bool {+ let index: Int = lock.withLock {+ let index = nextScanIndex+ nextScanIndex += 1+ startedScans += 1+ return index+ }+ resumeStartWaiters()+ await suspendUntilOpen(index)+ return results.indices.contains(index) ? results[index] : false+ }++ /// Releases the scan at `index`. Safe to call before that scan begins.+ func open(_ index: Int) {+ let waiter: CheckedContinuation<Void, Never>? = lock.withLock {+ openedGates.insert(index)+ return gateWaiters.removeValue(forKey: index)+ }+ waiter?.resume()+ }++ /// Suspends until at least `count` scans have begun, so a test can pin the+ /// start order of overlapping scans.+ func waitForScanStart(_ count: Int) async {+ await withCheckedContinuation { continuation in+ let alreadyStarted: Bool = lock.withLock {+ if startedScans >= count { return true }+ startWaiters.append((count, continuation))+ return false+ }+ if alreadyStarted { continuation.resume() }+ }+ }++ private func suspendUntilOpen(_ index: Int) async {+ await withCheckedContinuation { continuation in+ let alreadyOpen: Bool = lock.withLock {+ if openedGates.contains(index) { return true }+ gateWaiters[index] = continuation+ return false+ }+ if alreadyOpen { continuation.resume() }+ }+ }++ private func resumeStartWaiters() {+ let ready: [CheckedContinuation<Void, Never>] = lock.withLock {+ let matched = startWaiters.filter { $0.target <= startedScans }+ startWaiters.removeAll { $0.target <= startedScans }+ return matched.map(\.continuation)+ }+ ready.forEach { $0.resume() }+ } }
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5224d5a..f170c5b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Completing the unlock purchase no longer sometimes leaves the app still locked (T-1868). To know whether you have bought unlimited exports, the app asks the App Store — when it starts, and each time you bring it back to the front — and that question takes a moment to come back. If your purchase completed while an answer to an earlier question was still on its way, the app recorded the purchase and was then told, by that older answer, that you had bought nothing: the paywall came back and the export the purchase was meant to unblock was lost, even though the purchase had gone through and you had been charged. Restoring purchases on a new device could be undone the same way, by a question asked before the restore and answered after it. The reverse ordering hid a refund, leaving the unlock in place until the next check. Answers are now ranked by when the information behind them was gathered rather than by when they happen to arrive, so the most recent word on your purchase is the one that stands and an older answer can no longer overwrite it. A check cut short before it finishes is also no longer mistaken for "nothing purchased". - Choosing where to go while a document reloads now takes you there (T-1975). If a file changed on disk — or a URL document was refreshed — while you had it open, and during the moment the app spends preparing the new version you picked a table-of-contents entry, tapped a note, followed a link to a heading, or stepped to a search match, the reloaded document appeared at your saved reading position instead. What you chose was handed to the copy still on screen, which was about to be replaced, so nothing was left to say where you had asked to go and restoring your place won — and on a large document, where preparing the new version takes longest, that window is at its widest. The document on screen is now treated as superseded from the moment a reload starts rather than from the moment the new version is ready, so anything you choose in between is held for the version that is coming and takes precedence over your saved place, exactly as it already did when you chose a moment later. This holds when a file changes twice in quick succession, so a second reload beginning before the first has finished preparing still takes you where you asked rather than back to your saved place. Reloads you did not navigate during still return you to where you were reading, and once the reloaded document has taken you where you asked, the next reload restores your place normally. Scrolling while a reload prepares still counts too, however you do it — dragging, a trackpad or wheel, **Page Up** and **Page Down**, or **Scroll to Top** and **Scroll to Bottom** — because the document stays in front of you and stays scrollable the whole time: the place you scroll to is the place you are returned to. - Changing the reading font or text size no longer moves you somewhere else in the document (T-1965). Both settings already applied without reloading, but they reflow the whole document and nothing put you back afterwards: raising **Larger Text** to an accessibility size makes every block roughly three times as tall, so the text you were reading slid off the bottom of the screen and left you looking at something you had already been through. The app then recorded that new spot as where you were reading, so closing and reopening the document returned you to it as well. Your place is now kept across the change — including how far into a paragraph you were, so the same words stay in front of you rather than merely the same paragraph starting at the top — and a place you were never reading can no longer be saved while the document settles. Jumping somewhere while the change is settling wins: a table-of-contents entry, a link, a note, or a search match all take you where you asked, and the re-anchoring steps aside. Collapsing the section you were reading during the change leaves you at its heading rather than at content that is no longer shown. - A document that goes blank because its rendering process stopped now restores itself (T-1943). The app has always been able to recover from this — it reloads the document and puts back your theme, your reading position, your note markers, and any active search highlights — but nothing was ever watching for the rendering process to stop, so the recovery never actually ran. A large or image-heavy document whose renderer was shut down under memory pressure therefore showed an empty page, with no error and no way back except closing the file and opening it again. The app now watches for it and recovers on the spot. An ordinary failure to load — a link that goes nowhere, an image that cannot be fetched — is told apart from a stopped renderer, so it neither causes a needless reload nor stops the app watching for a real one afterwards. The recovery also covers its own failure: if the reload it starts cannot itself load the document, that counts as the recovery failing and is tried again, instead of leaving the page blank with nothing running. A reload that neither succeeds nor fails — one that simply never finishes — is covered too: it is given a generous time limit, well beyond what even a large document takes to appear, and is then treated as a failed recovery and tried again rather than leaving the page blank indefinitely. If reloading repeatedly fails to bring the document back, the app stops retrying rather than reloading over and over — and says so, with a banner offering to reload. Taking that reload also restores the document's ability to recover on its own again, so giving up is never permanent while the file stays open.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 2f08372..e7f3dac 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -95,7 +95,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, the export counter, and paywall presentation flag (`showPaywall`)-2. `EntitlementState` is tri-state (`.loading` / `.locked` / `.unlocked`); the paywall and Settings UI branch on it+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 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. Export gating: callers use `StoreManager.runGatedExport(retry:perform:)`, which returns `.loading` (no-op), presents the paywall on `.blocked`, or invokes the perform closure on `.allowed` / `.allowedWithNudge`
diff --git a/specs/bugfixes/concurrent-storekit-verification-race/report.md b/specs/bugfixes/concurrent-storekit-verification-race/report.mdnew file mode 100644index 0000000..85cd715--- /dev/null+++ b/specs/bugfixes/concurrent-storekit-verification-race/report.md@@ -0,0 +1,238 @@+# Bugfix Report: Concurrent StoreKit Verification Can Overwrite Newer Entitlement State++**Date:** 2026-08-10+**Status:** Fixed+**Ticket:** T-1868++## Description of the Issue++`StoreManager.verifyEntitlements()` scanned `Transaction.currentEntitlements`,+accumulated a `hasUnlock` flag across the scan's suspension points, and then+committed the resulting `EntitlementState` unconditionally. Because the scan+suspends, the other writers — `purchase()` and the `Transaction.updates`+listener — could commit newer entitlement truth while the scan was mid-flight.+The scan then finished and wrote its older snapshot on top.++**Reproduction steps:**++1. Launch the app (or foreground it) while `entitlementState != .unlocked`, so+ `prismApp.swift` starts a foreground `verifyEntitlements()` scan.+2. While that scan is still suspended, complete the unlock purchase (or have+ the App Store deliver a revocation on `Transaction.updates`).+3. The purchase commits `.unlocked` immediately.+4. The still-running scan finishes with its pre-purchase snapshot and commits+ `.locked` over it.++**Impact:** High. A user who has just paid is silently re-locked: the paywall+can reappear, `pendingExportAction` never fires, and the blocked export the+purchase was meant to unblock is lost. The inverse ordering keeps a refunded+user entitled until the next scan. The same hazard exists between two+overlapping scans, which is the realistic restore-vs-foreground case:+`restorePurchases()` does `AppStore.sync()` and then re-verifies, and a+foreground scan that started before the sync can clobber the post-sync result.++## Investigation Summary++Applied the four-phase Fagan inspection (`systematic-debugger`).++- **Symptoms examined:** entitlement state reverting to `.locked` after a+ successful purchase; entitlement surviving a revocation.+- **Code inspected:** `prism/Services/StoreManager.swift` (init task, `purchase`,+ `restorePurchases`, `verifyEntitlements`, `listenForTransactions`,+ `updateEntitlement`), `prism/prismApp.swift` scene-phase handler.+- **Defects identified:**+ - **D1** `verifyEntitlements()` committed a snapshot computed before its+ suspension window with no check that the state it overwrites is newer.+ - **D2** No ordering between two concurrent scans — last to *finish* won,+ which need not be last to *start*.+ - **D3** A cancelled scan truncates the `for await` early, leaving+ `hasUnlock == false`, and then commits `.locked` from a partial read.+ - **D4** The scan was welded to `Transaction.currentEntitlements`, so no test+ could express the interleaving. This is why the defect survived review.+- **Hypotheses ruled out:** off-MainActor mutation (every writer is inside the+ `@MainActor` class); a missing `finish()` (`currentEntitlements` only yields+ already-finished transactions, so that is correct as written).++## Discovered Root Cause++`entitlementState` was a shared mutable value with three independent writers and+no ordering discipline. A verification scan computed its answer across an+actor-reentrancy window and then committed it unconditionally, so **completion+order decided the final value rather than recency of information**.++**Defect type:** Race condition / stale-state commit (actor reentrancy).++**Why it occurred (Five Whys):**++1. Why does a successful unlock revert to locked? → `verifyEntitlements()`+ assigned `.locked` after the purchase assigned `.unlocked`.+2. Why did it assign `.locked`? → Its `hasUnlock` accumulator came from an+ entitlement read that began before the purchase existed.+3. Why was a pre-purchase snapshot allowed to be written after the purchase? →+ The commit depended only on the scan's own local variable, never on whether+ the state being overwritten was newer.+4. Why was there no such check? → The code treats `@MainActor` isolation as if+ it serialised the whole `async` method. It does not: `await` yields the actor+ and reopens it to other writers. Nothing in the type system flags this.+5. Why did the assumption go unchallenged? → Entitlement state had no single+ commit funnel and no notion of ordering, and the scan was welded to StoreKit,+ so no test could express "an event lands mid-scan".++**Contributing factors:** `@MainActor` reads as a stronger guarantee than it is;+the foreground re-verify in `prismApp.swift` is an unstructured `Task` that can+overlap the init task's scan freely.++## Resolution for the Issue++Entitlement writes are now ordered by a single monotonic **observation clock**.+Every writer stamps the clock at the moment it gathers its evidence — a+verification scan before it suspends, a transaction event as it commits — and a+write is applied only while its stamp is still the latest issued. A scan whose+evidence has since been superseded, by either a newer scan or a transaction+event, drops its result instead of committing it.++One counter suffices for both hazards precisely because the transaction event+takes a stamp too: "is my stamp still the latest?" simultaneously answers "did a+later scan start?" and "did an event land?". Dropping a superseded result loses+nothing, since by construction the value it would have written is older than+what is already in `entitlementState`.++**Changes made:**++- `prism/Services/EntitlementSource.swift` (new) — `EntitlementSource` protocol+ plus the production `StoreKitEntitlementSource` wrapping+ `Transaction.currentEntitlements`. Makes the scan injectable so the ordering+ rules are testable (addresses D4).+- `prism/Services/StoreManager.swift`:+ - `latestEntitlementObservation` — the monotonic clock.+ - `beginEntitlementObservation()` — bumps the clock and returns the new stamp,+ marking the caller as the most recent gatherer of evidence.+ - `commitEntitlement(_:observedAt:)` — the single commit point; applies a+ write only while its stamp is still the latest (addresses D1 and D2). One+ exception: while `entitlementState` is still `.loading`, any write is+ accepted — `.loading` carries zero information, so a scan cancelled after+ burning the latest stamp must not strand the state there by vetoing an+ earlier scan's genuine read. Only scans can burn a stamp without+ committing, so the exception can never resurrect a value a transaction+ event has superseded.+ - `verifyEntitlements()` — stamps before its `await`, and on resume refuses to+ commit a cancelled scan's truncated read (addresses D3).+ - `applyTransactionEvent(unlocked:)` — the single funnel for authoritative+ events, reached from `purchase()` and the `Transaction.updates` listener;+ takes a fresh stamp, which also supersedes any in-flight scan.++`StoreManager` stays on the MainActor, `entitlementState` stays a stored+`private(set)` property, and every caller's `await verifyEntitlements()` call+shape is unchanged. `prism/prismApp.swift` is untouched.++**Approach rationale:** Three independent implementations were developed and+compared (see `solution-comparison.md`). This one was selected as the smallest+change that fully closes the defect: it states the ordering rule once, at one+commit point, rather than re-checking it at each call site.++**Alternatives considered:**++- **Two counters — a transaction-event generation plus a verification sequence,+ checked separately at the commit site** (candidate A) — correct, but the+ second counter and two of its three guards are redundant once the event also+ stamps a shared clock.+- **An owned `EntitlementLedger` handing out single-use `ScanPermit`s, where+ validity is object identity** (candidate B) — the most rigorous option, since+ a stale scan has no reachable write path at all rather than being guarded+ against one. Rejected as disproportionate here: a new 122-line type, and it+ converts `entitlementState` from a stored to a computed property, widening the+ blast radius on an Observation-driven UI for a guarantee the simpler version+ already provides.+- **Serialising scans behind a chained `Task`** — fixes scan-vs-scan but not+ scan-vs-event, so a stamp check would still be needed; it also makes+ `restorePurchases()` queue behind an unrelated slow scan.+- **Moving entitlement state to its own actor** — moves state off the MainActor+ the UI reads it from, and does not by itself solve ordering: an actor+ serialises individual mutations, not a read-modify-write spanning a scan.++## Regression Test++**Test file:** `prismTests/StoreManagerTests.swift`++**Test names:**++- `staleVerificationDoesNotOverwritePurchase` — a scan started before a purchase+ must not commit its pre-purchase snapshot over the unlock.+- `staleVerificationDoesNotOverwriteRevocation` — the inverse: a scan started+ before a revocation must not re-grant the unlock.+- `olderVerificationDoesNotOverwriteNewerVerification` — models+ restore-vs-foreground; the later-started scan wins.+- `uncontendedVerificationCommits` — positive control: the guard must not block+ the normal path.+- `sequentialVerificationsEachCommit` — positive control: the guard must reset+ between scans rather than freezing after the first commit.+- `verificationAfterTransactionEventCommits` — positive control: an event must+ not permanently veto later scans.+- `cancelledVerificationDoesNotCommit` — a cancelled scan's truncated read is+ not evidence of "no entitlement" and must commit nothing (D3).+- `cancelledScanDoesNotStrandLoading` — a cancelled later scan that burned the+ latest stamp must not veto an earlier scan's genuine read while the state is+ still `.loading`.++The tests drive a `ScriptedEntitlementSource` double that holds each scan+suspended until the test releases it, so the interleaving is deterministic+rather than timing-dependent.++**Run command:**++```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' \+ -only-testing:prismTests/StoreManagerTests test+```++Confirmed red before the fix: the three ordering tests fail, the three positive+controls pass. After the fix (including the review round that added the+cancellation-path tests) all 19 tests in the suite pass.++**Caveat when running tests on this project:** while several worktrees build the+same bundle identifier (`me.nore.ig.prism`) concurrently, the macOS test host+fails to bootstrap — "The test runner hung before establishing connection" —+and xcodebuild reports `** TEST FAILED **` having run *zero* tests, which is+indistinguishable from a genuine failure by exit code alone. The underlying+cause traced during this fix was an unanswered container-sharing consent prompt+blocking `secinitd`'s per-bundle-ID queue, which wedges every sandboxed+`prism.app` launch machine-wide. Always confirm a nonzero test count in the+result bundle before believing a failure.++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/EntitlementSource.swift` | New — injectable entitlement scan seam plus the StoreKit-backed implementation |+| `prism/Services/StoreManager.swift` | Observation clock ordering entitlement commits; single transaction-event commit point; injected scan source |+| `prismTests/StoreManagerTests.swift` | Six ordering regression tests plus the `ScriptedEntitlementSource` double |+| `CHANGELOG.md` | User-facing entry under Unreleased / Fixed |+| `specs/bugfixes/.../solution-comparison.md` | Comparison of the three competing implementations |++## Verification++**Automated:**++- [x] Regression tests pass (19/19 in `StoreManagerTests`)+- [x] Full unit suite passes+- [x] SwiftLint clean++## Prevention++**Recommendations to avoid similar bugs:**++- Treat `@MainActor` on an `async` method as isolation for its *synchronous*+ regions only. Any value read before an `await` and written after it needs an+ explicit staleness check.+- Give shared state that has more than one writer a single commit funnel, and+ order commits explicitly when one writer computes its value across a+ suspension.+- Keep an injectable seam over slow external reads (StoreKit, network). The+ reason this bug was invisible was not subtlety — it was that the failing+ interleaving could not be written down as a test.++## Related++- Transit ticket T-1868+- `specs/inapp-purchase/` — entitlement and export-gating requirements (2.1–2.13)
diff --git a/specs/bugfixes/concurrent-storekit-verification-race/solution-comparison.md b/specs/bugfixes/concurrent-storekit-verification-race/solution-comparison.mdnew file mode 100644index 0000000..6db4a2c--- /dev/null+++ b/specs/bugfixes/concurrent-storekit-verification-race/solution-comparison.md@@ -0,0 +1,73 @@+# Solution Comparison: concurrent-storekit-verification-race++Three implementations were developed independently from the same red baseline+(commit `6d8f64d`: the injectable `EntitlementSource` seam plus six ordering+tests, three of them failing).++## Candidates++### Agent 1 (primary approach — two-counter generation guard)++- **Files changed:** `prism/Services/StoreManager.swift` (plus CHANGELOG and report)+- **Lines changed:** +58 / -0 in the source file+- **Tests:** pass — 17/17 in `StoreManagerTests`+- **Approach:** two monotonic counters — `transactionEventGeneration`, bumped by+ every transaction-event commit, and `verificationSequence`, bumped at the start+ of each scan. `verifyEntitlements()` captures both before its `await` and+ commits only if the scan was not cancelled, its sequence is still the newest+ started, and the event generation is unchanged.++### Agent 2 (alternative approach — owned state machine with scan permits)++- **Files changed:** `prism/Services/EntitlementLedger.swift` (new),+ `prism/Services/StoreManager.swift`+- **Lines changed:** +154 / -8+- **Tests:** pass — all six ordering tests plus the wider suite+- **Approach:** a new `@Observable @MainActor EntitlementLedger` becomes the sole+ owner of entitlement state. A scan cannot write state at all — it takes a+ single-use `ScanPermit` before reading and spends it after. The ledger holds+ exactly one live permit, so any newer writer displaces the outstanding one and+ spending a displaced permit is a no-op. Validity *is* object identity.++### Agent 3 (Kiro — independent perspective, unified observation clock)++- **Files changed:** `prism/Services/StoreManager.swift`+- **Lines changed:** +50 / -3+- **Tests:** pass (initial run was invalidated by a machine-wide sandbox-consent+ stall, see below; re-verified on a quiet machine)+- **Approach:** a single monotonic "observation clock". *Every* writer stamps it+ — a scan when it begins reading, a transaction event when it commits — and+ `commitEntitlement(_:observedAt:)` applies a write only while its stamp is+ still the latest issued.++## Selected: Agent 3 (Kiro), with the cancellation guard folded in++**Reason:** Kiro found the observation that the other two missed — because a+transaction event also *takes a stamp*, a single counter subsumes both ordering+hazards, so Agent 1's second counter and two of its three guards are redundant.+That yields the same semantics as Agent 1 in the smallest diff of the three, with+the ordering rule stated once at a single commit point rather than re-checked at+each call site. Agent 2's permit design is the most rigorous — it makes the stale+write unrepresentable rather than merely guarded — but it costs a new 122-line+type and converts `entitlementState` from a stored to a computed property,+widening the blast radius on an Observation-driven UI for a guarantee the simpler+version already delivers. Per the project's preference for simplicity, the+smallest change that fully closes the defect wins.++Agent 3 omitted the cancellation guard (defect D3), which Agents 1 and 2 both+caught. That single `guard !Task.isCancelled` was folded into the selected+solution rather than discarded — it is the same "never commit an untrustworthy+snapshot" rule and belongs with the rest.++## Environment note++Both Agent 1 and Agent 2 independently reported that `xcodebuild test` on this+machine fails with "The test runner hung before establishing connection" while+several worktrees build the same bundle identifier (`me.nore.ig.prism`)+concurrently. Agent 2 traced it to an unanswered container-sharing consent+prompt blocking `secinitd`'s per-bundle-ID queue, which wedges *every* sandboxed+`prism.app` launch machine-wide. In that state zero tests run while xcodebuild+still reports `** TEST FAILED **`, which is indistinguishable from a genuine+failure by exit code alone. Any "tests failed" result on this project must be+confirmed against a nonzero test count in the result bundle before it is+believed.
diff --git a/specs/bugfixes/concurrent-storekit-verification-race/implementation.md b/specs/bugfixes/concurrent-storekit-verification-race/implementation.mdnew file mode 100644index 0000000..bdea5ba--- /dev/null+++ b/specs/bugfixes/concurrent-storekit-verification-race/implementation.md@@ -0,0 +1,84 @@+# Implementation Explanation: Concurrent StoreKit Verification Race (T-1868)++Explains the fix on branch `T-1868/bugfix-concurrent-storekit-verification-race` at three expertise levels, then assesses completeness against the bugfix report.++---++## Beginner Level++### What Changed++The app sells one thing: an unlock that removes the export limit. To know whether you own it, the app asks Apple's App Store — when it starts and each time you bring it to the front. That question takes a moment to answer.++The bug: if you completed your purchase while one of those questions was still waiting for its answer, the old answer arrived afterwards and said "no purchase found" — because it was asked before you bought. The app believed it and locked you out again, even though you had just paid.++The fix gives every piece of information a timestamp-like number recording *when the app started gathering it*. When two answers disagree, the one gathered later wins. An old answer arriving late can no longer overwrite newer truth.++### Why It Matters++A paying customer was silently re-shown the paywall right after paying, and the export they paid to unblock was lost. The reverse could also happen: a refund could be hidden until the next check. Both are now impossible.++### Key Concepts++- **Entitlement**: the record proving you bought the unlock. The app's copy of it is `entitlementState`: still checking (`.loading`), not owned (`.locked`), or owned (`.unlocked`).+- **Race condition**: two things happening at overlapping times, where the outcome wrongly depends on which finishes last — like mailing two letters and trusting whichever arrives second, even if it was written first.+- **The clock**: a counter that goes up by one each time anyone starts gathering evidence. A result may only be saved if its number is still the highest — i.e. nobody started gathering newer evidence in the meantime.++---++## Intermediate Level++### Changes Overview++- `prism/Services/EntitlementSource.swift` (new): `EntitlementSource` protocol + `StoreKitEntitlementSource` wrapping `Transaction.currentEntitlements`. The scan is now injectable, so tests can hold a scan suspended while other events land.+- `prism/Services/StoreManager.swift`: a monotonic `latestEntitlementObservation: UInt64`; `beginEntitlementObservation()` stamps a writer; `commitEntitlement(_:observedAt:)` is the single commit point that drops stale-stamped writes; `applyTransactionEvent(unlocked:)` funnels purchase/`Transaction.updates` commits; `verifyEntitlements()` stamps *before* its `await` and refuses to commit a cancelled scan's truncated read.+- `prismTests/StoreManagerTests.swift`: eight interleaving tests driven by a `ScriptedEntitlementSource` double whose scans suspend until the test opens a gate.++### Implementation Approach++`StoreManager` is `@MainActor`, but actor isolation only serialises synchronous regions. `verifyEntitlements()` suspends at its `await`, reopening the actor to the two other writers of `entitlementState` (a completed `purchase(_:)` and the `Transaction.updates` listener). Before the fix, whichever writer *finished* last won — so a scan that began before a purchase could commit its pre-purchase snapshot on top of the unlock.++The fix orders writes by an observation clock. Every writer stamps the clock at the moment it gathers evidence: a scan before suspending, a transaction event synchronously as it commits. `commitEntitlement` applies a write only while its stamp is still the latest. One counter covers both hazards (scan-vs-scan and scan-vs-event) precisely because events also stamp.++Two guards ride along:++- A cancelled scan returns `false` from a truncated read of the entitlement sequence; that is not evidence of "no entitlement", so `verifyEntitlements` checks `Task.isCancelled` after resuming and commits nothing.+- While `entitlementState == .loading`, any write is accepted. `.loading` carries zero information, so no real result is staler than it — without this, a cancelled scan that burned the latest stamp would veto an earlier scan's genuine read and strand the state at `.loading`.++### Trade-offs++Three implementations were compared (`solution-comparison.md`): a two-counter guard (correct but partly redundant), an owned `EntitlementLedger` with single-use scan permits (most rigorous — makes the stale write unrepresentable — but a new 122-line type that turns `entitlementState` into a computed property, widening the blast radius on Observation-driven UI), and this single-clock design. The single clock was the smallest change that fully closes the defect, stating the rule once at one commit point. Serialising scans behind a chained Task and moving state to a separate actor were both rejected: neither orders a read-modify-write that spans a suspension.++---++## Expert Level++### Technical Deep Dive++The invariant: `entitlementState` reflects the write whose *evidence* is most recent, never the write that merely completed last. Stamps are handed out by `beginEntitlementObservation()` (increment-then-return on the MainActor, so allocation is totally ordered), and `commitEntitlement` enforces `observation == latestEntitlementObservation || entitlementState == .loading`.++Why the `.loading` exception is sound: only scans can burn a stamp without committing — `applyTransactionEvent` stamps and commits in one synchronous MainActor region, no suspension between. Therefore if state is still `.loading` at commit time, no transaction event has ever committed, so accepting a stale-stamped scan result cannot resurrect anything an event superseded. If a newer scan is still in flight, its stamp remains the latest and its later commit overwrites the bootstrap value — convergence is preserved.++Cancellation ordering: the `Task.isCancelled` guard sits *after* the `await`, which is the only correct spot — cancellation during the scan is exactly what truncates the sequence read, and the guard must see the post-resume flag.++### Architecture Impact++- The public surface is unchanged: `verifyEntitlements()` keeps its signature; `entitlementState` stays a stored `private(set)` property, so Observation-driven UI is untouched; `prismApp.swift` needs no change.+- `applyTransactionEvent(unlocked:)` is internal rather than private so tests can interleave an authoritative event into a scan's suspension window — StoreKit itself cannot be driven from unit tests. It is also the production funnel from `updateEntitlement(from:)`, so it is not test-only API.+- The observation-clock idiom matches the codebase's existing per-subsystem generation counters (`parseGeneration`, `loadGeneration`, `renderGeneration`); no shared abstraction was warranted.++### Potential Issues++- **Accepted residual**: a scan cancelled after burning the latest stamp while state is a *concrete* value drops an earlier scan's genuine result; state keeps its previous value until the next foreground scan or transaction event. Both stale values are self-correcting on the next event, and in production scans are only cancelled at deinit, so this is theoretical.+- `restorePurchases()` results can be superseded by a foreground scan that starts after `AppStore.sync()` — which is correct, since the later scan reads the post-sync world.+- The test double is `@unchecked Sendable` over an `NSLock`; all mutable state (gates, waiters, counters) is touched only inside `lock.withLock`, and continuation resumes happen outside the lock, so no lost-wakeup or deadlock path exists.++---++## Completeness Assessment++**Fully implemented:** all four defects from the report (D1 stale commit, D2 scan-vs-scan ordering, D3 cancelled-scan truncated read, D4 untestable interleaving) have both a code change and at least one pinning test. The three failure interleavings and five guard-behaviour controls are all covered; 19/19 `StoreManagerTests` pass locally.++**Partially implemented / accepted residuals:** the cancelled-stale-stamp veto over a concrete state (above) is documented in the code comment and here, not guarded — accepted as self-correcting.++**Missing:** nothing found. Every requirement stated in the report maps to code that can be explained without hand-waving; no divergence between report and implementation remains after the report was updated to cover the `.loading` bootstrap exception and the two cancellation tests.
The report.md, CLAUDE.md, and implementation.md changes from this review are in the working tree only. Commit them to the branch before pushing, or the published PR will still carry the stale report.
The red checks on PR #360 are zero-job billing failures, not code failures. Validation is local: StoreManagerTests 19/19 on macOS, SwiftLint 0 violations. Run make build-ios / make build-macos / full make test before merge if the usual pre-merge bar applies; the full macOS suite passed earlier apart from three known load-induced flakes unrelated to StoreKit.
Both the report and solution-comparison document that concurrent worktree builds of the same bundle ID can wedge every sandboxed prism.app launch machine-wide ('test runner hung before establishing connection', zero tests run, ** TEST FAILED **). Confirm a nonzero test count before believing any failure on this project.