prism branch T-2152/bugfix-...-stale-state commits 2 + review edits files 3 touched lines +153 / -11 tests 27/27 iOS sim builds macOS 0 errors

Pre-push review: T-2152 superseded entitlement scan stale state

Extends the T-1868 observation clock into the .loading bootstrap window — the one place where completion order, not recency, still decided the winner. PR #383.

At a glance

  • Correctness: verified. Hold-back, promotion, and freshest-candidate selection all trace correctly across 2- and 3-scan interleavings and against transaction events. No stamp leak, no lost result.
  • New regression, narrow but user-visible: a successful restore can now report “No purchases to restore.” because restorePurchases() leaks .loading to callers that have no branch for it.
  • The multi-window question resolves structurally. The new machinery is gated on .loading; a pending export retry requires .blocked, which requires .locked. Once either can happen the other is permanently inert, so a promotion can never collide with a blocked export.
  • The silent .loading export no-op is not reachable — all three runGatedExport call sites disable their control. But nothing tells the user why it is greyed out, and this change widens that window.
  • Overclaim found and fixed: “the paywall cannot be stranded on .loading indefinitely”. A scan that never returns does strand it — there is no timeout. It self-heals on the next completing scan, which on macOS may mean the next app switch.
  • Proportionality: trim nothing. I initially thought the Set could collapse to a counter plus a watermark. That is wrong, and reachably so — the counterexample is in the Decisions section.

Verdict

Ready to push — file one follow-up first

The fix itself is correct. I traced every interleaving of two and three overlapping scans against transaction events and could not construct a stamp leak, a lost result, or a case where a held-back value overwrites a fresher one. It reuses the existing latestEntitlementObservation namespace rather than adding a second clock, and it makes an invariant CLAUDE.md already asserted — “recency of information wins, never completion order” — actually true, where it was quietly false inside .loading. Ship it.

But it introduces one new user-visible regression that should be ticketed before push. restorePurchases() returns the shared entitlementState rather than its own scan's finding. When that scan is now held back, it returns .loading, and both call sites render anything that is not .unlocked as “No purchases to restore.” — telling a paying customer they own nothing. Three independent reviewers found this separately. It is narrow (needs a restore during bootstrap with an overlapping scan) and it shares a root cause with a defect that already exists for the .locked case, so it does not block this fix — but it is new, and it is on a paid flow.

Two documentation overclaims were found and fixed in the working tree; those edits are uncommitted.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism sells a one-time unlock. When the app starts it asks the App Store “has this person paid?” That takes a moment, so until an answer arrives the app sits in a state called loading, during which the export buttons are greyed out.

The app can end up asking that question several times at once — at launch, when you switch back to it, and after tapping “Restore Purchases”. The answers can come back in a different order than the questions went out. The old code had a rule for that: whoever asked most recently wins, because they read a more current world. But the rule had a hole. While still in loading, the app accepted any answer, including an out-of-date one, because having some answer felt better than having none.

Why it matters

An out-of-date answer can be the wrong answer. Someone who had just paid could see a “you need to buy this” screen flash up before the correct answer landed. This change plugs the hole: a stale answer arriving while a fresher question is still unanswered is set aside rather than shown, and released only if the fresher question turns out never to be coming.

Key concepts

  • Stale read — information that was true when gathered but has since been overtaken.
  • Ordering by recency — picking a winner by when information was gathered, not when it arrived.
  • Liveness — the guarantee that the app eventually leaves loading rather than waiting forever. Holding an answer back trades a little liveness for correctness, which is why the fix needs a release valve — and why the review looked hard at whether that valve always opens.

Architecture

StoreManager is @MainActor-isolated, but MainActor isolation only serialises synchronous regions. verifyEntitlements() suspends at await entitlementSource.hasUnlockEntitlement(), which reopens the actor to the two other writers of entitlementState (a completed purchase, and the Transaction.updates listener). T-1868 addressed this with a monotonic observation clock: every writer stamps itself when it begins gathering evidence, and commitEntitlement applies a write only while that stamp is still the latest issued.

The defect

T-1868's guard carried an exception: observation == latest || entitlementState == .loading. The second disjunct existed for a real reason — a scan that takes the newest stamp and is then cancelled without committing would otherwise veto every earlier scan's genuine read and strand the app at .loading. But it was unconditional, so inside the bootstrap window completion order decided the winner again. The clock worked everywhere except inside its own escape hatch.

The fix

Three additions, all reusing the existing stamp namespace:

  • outstandingScanObservations: Set<UInt64> — stamps taken but not yet resolved. Only scans are ever outstanding; a transaction event stamps and commits with no suspension between.
  • pendingLoadingFallback — a stale-but-real result withheld because something newer was still outstanding. Only the freshest candidate is kept.
  • promotePendingLoadingFallbackIfUnblocked() — run from a defer on every scan resolution, publishing the held value once nothing newer is outstanding.

Trade-offs

The cost is latency: .loading now closes on the slowest overlapping scan rather than the fastest. That matters more than it first looks, because the scenePhase observer lives in MainContentView, instantiated once per window — N windows produce N concurrent scans on foreground, and the guard that skips the scan (entitlementState != .unlocked) is true during bootstrap. The trade is still right: a briefly-wrong paywall is worse than a briefly-longer spinner, and every gated control is disabled meanwhile.

Interleaving analysis

  • Hold-back correctness. With A(1), B(2), C(3): B resolves first with latest=3 and C outstanding → held as fallback(2). A then resolves, is blocked by C, and 1 > 2 is false so it does not displace B. The freshest-candidate comparison at :423 is load-bearing.
  • The stale-accept branch cannot discard a fresher fallback. Reaching it requires no outstanding scan newer than observation. A fresher fallback implies its scan already resolved; if it resolved by committing, state left .loading and the branch is unreachable, and if it resolved by cancelling, its own defer already promoted. So pendingLoadingFallback = nil at :428 only ever drops an older or absent candidate.
  • No stamp leak. remove(observation) at :271 runs unconditionally before the Task.isCancelled guard, and hasUnlockEntitlement() is non-throwing, so no exit path separates insert from remove today.
  • Transaction events are never delayed. applyTransactionEvent stamps and commits with no suspension — verified through purchase(_:) (which suspends only at transaction.finish(), after the commit) and listenForTransactions(). It therefore always holds the latest stamp, takes the equality branch, and clears any fallback.

Where the reasoning ends

Promotion fires only from verifyEntitlements's defer, i.e. only when a blocking scan returns. StoreKitEntitlementSource.hasUnlockEntitlement() iterates Transaction.currentEntitlements with no timeout and no cancellation. A scan that never terminates keeps its stamp outstanding and the fallback held — a state the pre-fix code escaped by publishing the older answer. It is not permanent: any later scan holds the newest stamp and commits through the unconditional branch, so the next foreground heals it. On macOS, where a frontmost app sees no scenePhase transition, that may mean the next app switch.

Reachability of the promotion path

The only non-committing exit is Task.isCancelled. Every production caller uses an unstructured Task { }prismApp.swift:426, initTask, and PaywallSheet.swift:154 / SettingsView.swift:518 for restore. None is a SwiftUI .task tied to view lifetime; the sole production cancellation is initTask?.cancel() in deinit. The promotion therefore encodes an invariant more than it services a live path — defensible, since the pre-existing cancelledScanDoesNotStrandLoading test depends on it, but the original comment cited a .task scope that does not exist and thereby implied hangs were covered when only cancellation is.

Disjointness with the per-scene paywall

Both new branches guard on entitlementState == .loading. A pending export retry requires a .blocked gate, which checkExport() returns only from .locked. Nothing writes .loading after init, so once any scan publishes, promotion is disabled for the process lifetime. A promotion can never fire while a PaywallPresenter holds a pending retry, and handleDismiss's captured-baseline comparison is untouched. A purchase made during .loading via Settings → paywall.present() (ungated) still unlocks immediately, because the transaction event outranks everything.

Important changes — detailed

commitEntitlement: the .loading exception becomes conditional

prism/Services/StoreManager.swift

Why it matters. This is the defect and the fix. The unconditional `|| entitlementState == .loading` disjunct let completion order decide the winner inside the bootstrap window, reopening for the pre-bootstrap case exactly what T-1868 closed for the post-bootstrap one.

What to look at. StoreManager.swift:416-434

Takeaway. An escape hatch added for liveness ("accept anything rather than stall") is itself an ordering rule and deserves the same scrutiny as the rule it bypasses. The fix is to condition the hatch on nothing better still being in flight — not to remove it.
Rationale. Reuses the existing `latestEntitlementObservation` stamp namespace rather than adding a parallel ordering mechanism, so there remains one clock to reason about.

outstandingScanObservations: tracking taken-but-unresolved stamps

prism/Services/StoreManager.swift

Why it matters. The state that makes the condition expressible. Correctness rests on the insert/remove pair having no exit path between them — true today because `hasUnlockEntitlement()` is non-throwing and `remove` precedes the cancellation guard, but held by convention rather than by construction.

What to look at. StoreManager.swift:125-131, 264, 271

Takeaway. When a stamp can be taken and then abandoned, a latest-wins clock is not enough on its own — you also need to know which stamps are still live. Recording that separately is far cheaper than trying to make the clock account for it.
Rationale. Only scans are ever outstanding: a transaction event stamps and commits synchronously on the MainActor, verified through both `purchase(_:)` and `listenForTransactions()`.

promotePendingLoadingFallbackIfUnblocked via defer

prism/Services/StoreManager.swift

Why it matters. The release valve. Withholding a value without one would be strictly worse than the bug being fixed, since a permanently `.loading` app disables every gated export. Worth knowing it covers cancellation only — not a hung scan.

What to look at. StoreManager.swift:272, 436-455

Takeaway. A `defer` registered right after the bookkeeping it depends on guarantees a post-condition on every exit path — but only on paths that reach the `defer`. A never-returning `await` escapes it entirely, which is exactly the failure this valve does not cover.
Rationale. Firing from a `defer` means every scan resolution re-evaluates the held value, so no separate scheduling or notification machinery is needed.

Two regression tests written red before the fix

prismTests/StoreManagerTests.swift

Why it matters. The first pins the hold-back, the second pins the promotion. The author ran the mutation explicitly — reverted only StoreManager.swift, kept the tests, and exactly these two failed with 25 passing.

What to look at. StoreManagerTests.swift:453-518

Takeaway. These mirror the pre-existing T-1868 test `olderVerificationDoesNotOverwriteNewerVerification` but seed `.loading` instead of `.locked` — the gap was visible as a missing row in an existing test matrix. Worth looking for that shape elsewhere.
Rationale. Written red-phase first (commit 95a1e092 carries the tests with the fix deliberately absent), so they are known to fail without the fix rather than assumed to.

Key decisions

Hold the stale result back rather than drop it.

Dropping a superseded result outright is simpler, but it reinstates precisely the strand T-1868's .loading exception was introduced to prevent: a newer scan that burns the latest stamp and never commits would veto every earlier genuine read. The pre-existing test cancelledScanDoesNotStrandLoading (StoreManagerTests.swift:413) pins that and would fail. Holding the value is what makes this fix strictly additive to T-1868 rather than a partial revert of it.

A Set of outstanding stamps — and why a scalar watermark will not do.

The set is only ever queried as contains(where: { $0 > x }), never for membership, which invites replacing it with an outstanding-count plus a high-water stamp. I assumed that substitution was merely more conservative. It is not — it is wrong, and reachably so.

Take A(1), B(2), C(3) all outstanding. C resolves cancelled; B then resolves. With the set, the only outstanding stamp is A(1), nothing exceeds 2, so B — the freshest resolved answer — publishes. Correct. With a count plus watermark, the watermark is still 3 and can only reset when the count drains, so B is wrongly held; A then resolves, the count hits zero, and A's stale-accept branch publishes A's older value while nil-ing out B's held one. That is the exact bug class being fixed here.

Three-plus concurrent scans are ordinary, not exotic (see the window-count note). The strict > comparisons at :419, :423 and :444 are therefore load-bearing. Trim nothing.

(inferred — not stated by the author.)
Keep only the freshest held-back candidate.

With three or more overlapping scans, several superseded results can arrive while the newest is still outstanding. Keeping the highest-stamped one means that if the blocker is ultimately cancelled, the most current available answer is published rather than an arbitrary one. The comparison at StoreManager.swift:423 enforces this and is currently untested.

(inferred — not stated by the author.)
No timeout added to the entitlement scan.

StoreKitEntitlementSource.hasUnlockEntitlement() iterates Transaction.currentEntitlements unbounded. Withholding results makes the exit from .loading depend on the slowest scan terminating where before it depended on the fastest. A timeout would close that, but it carries its own semantics (what does a timed-out scan commit?) and does not belong in this fix. The mitigating fact is that the condition self-heals on the next completing scan.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorStoreManager.swift:236-240 / SettingsView.swift:520 — restore reports a false negative`restorePurchases()` returns the shared `entitlementState` rather than its own scan's finding. When that scan is now held back, it returns `.loading`, and both consumers treat anything that is not `.unlocked` as failure: SettingsView.swift:520-522 sets "No purchases to restore." and PaywallSheet.swift:163-170 announces the same. So a paying customer restoring during bootstrap can be told they own nothing. Reachable: Settings shows the Restore row whenever state is not `.unlocked` — including `.loading` — with no `.disabled` (SettingsView.swift:409-437), and dismissing the `AppStore.sync()` sign-in sheet drives scenePhase to `.active`, firing a newer scan. Pre-fix this path committed under the unconditional `.loading` exception and returned a real answer. Found independently by all three reviewers.Not fixed here. The remedy — have `verifyEntitlements()` surface its own result so `restorePurchases` reports that instead of re-reading the shared field — changes the entitlement API surface, which is not a review-time edit on the very PR that reworks that surface. Note the same defect already exists for the `.locked` case pre-fix (a dropped stale scan yields the same wrong message), so the root cause predates this diff and deserves its own ticket rather than a patch here.
minorCHANGELOG.md:23 — two overclaims(1) "...so the paywall cannot be stranded on `.loading` indefinitely" — the PR body repeats it as "withholding can never permanently strand `.loading`". True for cancellation, false in general: promotion runs only from `verifyEntitlements`'s `defer`, so it needs the blocking scan to RETURN, and `hasUnlockEntitlement()` has no timeout. (2) "promoted only once every newer scan has resolved — either by committing, or by being cancelled" reads as though promotion happens in both cases; when the newer scan commits, the held value is discarded at :432, never promoted.Rewrote the sentence: the commit arm now explicitly drops the held value, the cancellation arm releases it, and the latency trade-off is stated (loading ends on the last overlapping scan, with gated controls disabled meanwhile so nothing silently does nothing). Fixed in the working tree, uncommitted.
minorStoreManager.swift:438 — comment cites a mechanism that does not exist"a newer scan's external cancellation (e.g. its `.task` scope exiting)". No production caller is in a SwiftUI `.task`: prismApp.swift:426, `initTask`, and PaywallSheet.swift:154 / SettingsView.swift:518 are all unstructured `Task { }` that view teardown does not cancel. The only production cancellation is `initTask?.cancel()` in `deinit`. The comment thereby implies hangs are covered when only cancellation is. It also says "every gated export silently no-ops while `.loading`", overstating impact — all three call sites disable their control, so it is a greyed-out button, not silence.Rewrote: cancellation named as the sole non-committing exit, accurate 'gated export surface is disabled' phrasing, and an explicit note that promotion fires only when a blocking scan returns — with the no-timeout caveat spelled out. Fixed in the working tree, uncommitted.
minorprismTests/StoreManagerTests.swift:453 — the Set is untested against a counterBoth new tests use exactly two scans, so neither discriminates the `Set` from an outstanding-counter. A future simplifier swapping `Set<UInt64>` for an `Int` keeps the suite green while reintroducing the bug class (see the Decisions section for the concrete three-scan counterexample). Also untested: the freshest-candidate comparison at :423, and a transaction event landing while a fallback is held (that the commit clears it at :432).Not fixed — the skill forbids editing tests during review when no bug is being corrected, and none of these is a live defect. Strongly recommended as a follow-up, because the three-scan test is what defends the design against its own most tempting simplification.
minorStoreManager.swift:264-272 — insert and remove can drift apart`insert` is at :264 and `remove` at :271, separated by the `await`, with only the promotion in the `defer` at :272. Correct today (non-throwing await, no early return between them) but held by convention: any future early return or `throws` added in that span leaks a stamp permanently, which blocks every older result from ever publishing.Not applied. Folding the remove into the defer registered at the insert is behaviour-equivalent — both queries use strict `>`, so a scan's own stamp can never match itself — and two reviewers recommended it. I left it alone deliberately: relocating a statement inside a concurrency fix, on the PR whose entire subject is that ordering logic and without the author present, is a poor trade against a hazard that is purely hypothetical today. Worth doing as a deliberate follow-up.
minorspecs/inapp-purchase/decision_log.md — undocumented decisionHold-back-versus-drop is a genuine decision with a real rejected alternative and a real consequence (the `.loading` window now closes on the slowest overlapping scan). Decision 7's rationale at decision_log.md:222 — loading "typically completes in milliseconds" — is now marginally less true. The immediately preceding sibling PR T-1841 (#377) touched this same file and did add decision-log entries; T-1868 did not, so the convention is mixed.Not fixed — authoring a spec decision is not a review correction, and the code comments already carry the reasoning in unusual detail. Recommended, not required.
nitStoreManager.swift:429 / :433 / :445 — three write sites, one asymmetryThe stale-accept branch writes `entitlementState = next` unconditionally; the latest-stamp branch guards with `if entitlementState != next`. Both are correct — :429 is reachable only from `.loading` with a non-`.loading` value, so the write is always a real change, whereas :433 can legitimately re-commit an identical value and `@Observable` fires on same-value writes. But the asymmetry is undocumented and will stop every future reader.Left as is; noted. Funnelling all three through a single deduping `publish(_:)` helper would make the question disappear, which is a tidy follow-up but not worth touching the commit point for now.
nitStoreManager.swift:490 — DEBUG helper does not clear the fallback`setEntitlementStateForTesting` writes `entitlementState` directly, bypassing `commitEntitlement`, so it can leave `pendingLoadingFallback` set. Not a bug today: it moves state out of `.loading` and both readers guard on `.loading`, so any held value is inert. All current call sites pass `.unlocked` on stores that never run scans.No action. Recorded so a future change to that helper — particularly one that sets state back to `.loading` — does not assume the invariant holds by construction.

Per-file diffs

Click to expand.

prism/Services/StoreManager.swift Modified +76 / -11
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex 9b4d69be..85bf4757 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -122,6 +122,22 @@ final class StoreManager {     @ObservationIgnored     private var latestEntitlementObservation: 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+    /// event stamps and commits synchronously on the `MainActor`, so it never+    /// suspends between the two (T-2152).+    @ObservationIgnored+    private var outstandingScanObservations: Set<UInt64> = []++    /// A stale-but-real scan result held back during the `.loading` bootstrap+    /// window because a newer scan was still outstanding when it arrived+    /// (T-2152). Promoted once every newer scan is known to have resolved+    /// without committing; discarded the moment any commit actually lands,+    /// since that is by definition newer information.+    @ObservationIgnored+    private var pendingLoadingFallback: (state: EntitlementState, observation: UInt64)?+     // MARK: - Init      convenience init() {@@ -245,8 +261,16 @@ final class StoreManager {         // stamp captures when this scan began reading the world; the commit         // below is dropped if newer evidence has arrived since.         let observation = beginEntitlementObservation()+        outstandingScanObservations.insert(observation)         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).+        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).@@ -372,23 +396,64 @@ final class StoreManager {     /// — 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.+    /// One exception: while `entitlementState` is still `.loading`, a stale+    /// write may still be accepted. `.loading` carries zero information, so no+    /// stamp can be staler than it — and without some exception here, 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.+    ///+    /// That exception cannot be unconditional, though (T-2152): if a NEWER scan+    /// is still outstanding — mid-flight, not yet resolved one way or the+    /// other — it might commit something different, so an older result must+    /// not publish yet. It is held in `pendingLoadingFallback` instead and+    /// promoted by `promotePendingLoadingFallbackIfUnblocked()` once every+    /// newer scan is known to have resolved without committing. Only scans can+    /// burn a stamp without committing (transaction events stamp and commit+    /// synchronously), so this can never resurrect a value a transaction event+    /// has superseded — a transaction event's own commit always holds the+    /// latest stamp and takes the unconditional branch below.     private func commitEntitlement(_ next: EntitlementState, observedAt observation: UInt64) {-        guard observation == latestEntitlementObservation || entitlementState == .loading else {+        guard observation == latestEntitlementObservation else {+            guard entitlementState == .loading else { return }+            guard !outstandingScanObservations.contains(where: { $0 > observation }) else {+                // A newer scan might still supersede this result. Hold it back+                // rather than dropping it outright, keeping only the freshest+                // held-back candidate.+                if observation > (pendingLoadingFallback?.observation ?? 0) {+                    pendingLoadingFallback = (next, observation)+                }+                return+            }+            pendingLoadingFallback = nil+            entitlementState = next             return         }+        pendingLoadingFallback = nil         if entitlementState != next { entitlementState = next }     } +    /// Promotes a held-back scan result once every newer scan that was+    /// blocking it has resolved without committing (T-2152). Without this, a+    /// newer scan cancelled before it commits would strand `entitlementState`+    /// at `.loading` — worse than the stale-publish bug this mechanism exists+    /// to prevent, since every gated export surface is disabled while+    /// `.loading`. Cancellation is the only way a scan resolves without+    /// 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.+    private func promotePendingLoadingFallbackIfUnblocked() {+        guard entitlementState == .loading, let fallback = pendingLoadingFallback else { return }+        guard !outstandingScanObservations.contains(where: { $0 > fallback.observation }) else { return }+        entitlementState = fallback.state+        pendingLoadingFallback = nil+    }+     private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {         switch result {         case .verified(let value):
prismTests/StoreManagerTests.swift Modified +76 / -0
diff --git a/prismTests/StoreManagerTests.swift b/prismTests/StoreManagerTests.swiftindex ae38ad4c..d623a0b6 100644--- a/prismTests/StoreManagerTests.swift+++ b/prismTests/StoreManagerTests.swift@@ -440,6 +440,82 @@ struct StoreManagerTests {         // until the next writer event, wedging every gated export.         #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.+    //+    // Before the fix, `commitEntitlement` accepted ANY stamp while+    // `entitlementState == .loading`, regardless of whether a newer scan was+    // still in flight. Completion order (not the observation clock) decided+    // the winner during that window — the same defect T-1868 fixed for the+    // post-bootstrap case, reopened for the pre-bootstrap one.++    @Test("An older scan cannot publish while a newer scan is still pending, even while loading")+    @MainActor+    func olderScanCannotPublishWhileNewerScanStillPendingDuringLoading() async {+        // Models the exact T-2152 trigger: scan A reads the pre-sync world,+        // scan B starts (e.g. after AppStore.sync()) and remains in flight,+        // and A completes first.+        let source = ScriptedEntitlementSource(results: [false, true])+        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)++        // Release only A. B (the newer, still-live scan) stays suspended.+        source.open(0)+        await scanA.value++        // Expected: A's superseded result must not publish — B still owns+        // the latest stamp and might yet commit something different.+        // Before the fix: state incorrectly flips to `.locked` here because+        // the `entitlementState == .loading` exception ignored B entirely.+        #expect(store.entitlementState == .loading)++        source.open(1)+        await scanB.value++        // B, the genuinely latest scan, wins once it resolves.+        #expect(store.entitlementState == .unlocked)+    }++    @Test("A held-back result is promoted once the newer scan holding it back turns out to be cancelled")+    @MainActor+    func heldBackResultPromotedWhenBlockingNewerScanIsCancelled() async {+        // Companion liveness check for the fix above: withholding A's result+        // while B is pending must not permanently strand `.loading` if B+        // itself never commits (external cancellation, e.g. a `.task` scope+        // exit). A's genuine read must still surface once B's fate is known.+        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)++        source.open(0)+        await first.value+        #expect(store.entitlementState == .loading)++        second.cancel()+        source.open(1)+        await second.value++        // A's held-back result must be promoted now that B is known to never+        // commit.+        #expect(store.entitlementState == .unlocked)+    } }  // MARK: - Test doubles
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8c650b61..78f21bf5 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- 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, and is promoted only once every newer scan has resolved — either by committing, or by being cancelled without ever committing, which still lets the held-back result through 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. - Opening an SVG image full screen now shows that document's image, rather than one belonging to a different document (T-1866). Two documents that each referred to an SVG by the same relative name — `./diagram.svg` sitting next to each file, say — shared a single entry in the store of rendered previews, because that store was keyed on the name written in the markdown instead of on the file the name actually resolves to. Whichever document you opened first won: opening the second document's diagram showed you the first one's picture, with nothing to indicate it was the wrong one, and it kept doing so for the rest of the session. Rendered SVG previews are now keyed on the resolved image itself — a file's full path, a remote address, or the contents of an inline image — so same-named images in different documents can no longer stand in for one another. Reopening the same image in the same document still comes back instantly from the store, as before, and two documents that embed a byte-for-byte identical inline image do still share one rendered preview, which is correct: identical content renders identically. - A note imported from a document, anchored to a nested list item (a sub-item under a top-level list item), now shows its quoted text and the section it belongs to (T-1871). In the notes pane it appeared with a blank quote and no heading, and it could not be told apart from any other nested-item note in the document. The note itself was always attached to the correct item — only the surrounding context was missing, and it was missing every time the document was opened, not just on a reload. Imported notes are rebuilt from the document on each open, and that rebuild only recognised top-level list-item identifiers, so a nested item's identifier was never matched and the context came back empty. Nested identifiers are now recognised the same way every other part of the app that addresses list items already recognises them, so an imported note on a nested item gets the same context as one on a top-level item. - Refreshing a document opened from a URL no longer leaves a background download running loose after you close it (T-1805). Tapping Refresh started an untracked download with no way to stop it: closing the document, or navigating to a different one, before the download finished let it keep running, and when it finally completed it could still overwrite that URL's title in Recent Files — even though you had already moved on — or race a second refresh for a stale result. Closing the document, or starting another refresh, now cancels the one in flight, and a refresh that has been superseded or cancelled no longer writes its result anywhere.

Things to double-check

The multi-window question — resolved, no action needed.

Worth recording the argument, since it is the non-obvious part and it resolves structurally rather than probabilistically. StoreManager is app-wide, PaywallPresenter per-scene (T-1779), so the concern is a promoted result landing while another scene holds a blocked export. It cannot happen: both new branches guard on entitlementState == .loading (:418, :443); a pending retry exists only after a .blocked gate (PaywallPresenter.swift:123-124); and checkExport() returns .blocked only from .locked (:196-199). Nothing writes .loading after init, so the moment any scan publishes, promotion is inert for the process lifetime. handleDismiss's captured-baseline comparison is untouched. A purchase during .loading via Settings → paywall.present() (ungated) still unlocks immediately, because a transaction event outranks the whole mechanism.

Concurrency multiplier: N windows, N+1 scans.

.onChange(of: scenePhase) at prismApp.swift:422 sits in MainContentView.body (declared at :241), the WindowGroup content at :108 — one instance per window. Its guard is entitlementState != .unlocked, which is true during .loading, so every window's .active transition during bootstrap spawns another scan: N windows restored at launch give up to N+1 concurrent scans, plus one per Restore tap. Each is an unstructured Task nothing cancels. This is what turns "wait for the slowest overlapping scan" from theoretical into measurable, and it is also what makes the three-scan counterexample in the Decisions section reachable rather than contrived. If the loading state is ever reported as sluggish, coalescing concurrent scans the way retryProductFetch already does (:242-254) is the first thing to try — with the caveat that restorePurchases must force a fresh scan, since its scan has to start after AppStore.sync().

The export gate's silent no-op is not reachable — but nothing explains the wait.

runGatedExport treats .loading as a bare break (PaywallPresenter.swift:119-120) — no paywall, no export, no feedback. All three call sites guard it: CopyNotesButton.swift:94 and InlineNotesShareHelper.swift:230 apply .disabled(entitlementState == .loading), and the macOS ExportFileButton derives isAvailable from isExportMenuAvailable (DocumentReaderView.swift:392), disabling both the menu item and its Cmd+Shift+S shortcut. So a longer window means a longer-disabled control, not a dead one — the answer to the reachability question is no.

The residual is presentational: there is no spinner or explanation anywhere for entitlement loading. RemainingExportsLabel renders nothing while .loading, and the ProgressViews in PaywallSheet and SettingsView track the product fetch, not the entitlement. The entire user-visible signal is three greyed-out controls with no stated reason — pre-existing, but this change widens the window in which it is showing.

Verification was run, not assumed.

Per the known-bad environment, macOS-destination tests were not run. The iOS Simulator run used an isolated -derivedDataPath, and the result was reconciled rather than read off the top line — xcodebuild exit 0 and xcresulttool reporting 27 executed / 27 passed / 0 failed / 0 skipped. The executed count matters here because a zero-test run has been observed printing ** TEST SUCCEEDED **. make lint: 0 violations across 545 files, re-run after the review edits. xcodebuild build -destination platform=macOS: BUILD SUCCEEDED, 0 errors; the 8 warnings in StoreManager.swift sit at lines 157, 175 and 361-364, none of which this diff touches — pre-existing, from T-1841's generic selectCatalog and the initialiser default arguments.

Worth naming a limit: none of the three review agents could run the suite themselves (build-DB contention across concurrent worktrees), so their correctness claims are hand-traced. The passing numbers above are from my own reconciled run.